detached dae7738

Merge pull request #12 from bnema/feat/subagents-v1

feat: add durable subagents supervisor
commit
dae7738e374d0fa9a8072dc0dd951c62ac8f05b0
author
bnema · · 2026-08-05 04:38 UTC
committer
GitHub · · 2026-08-05 04:38 UTC
merge of
5f266b1 + f319fb4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
82 files · +11836 -4140
M AGENTS.md +1 -1
@@ -42,7 +42,7 @@ Put behavior in the package that owns the concern:
4242 | `packages/agent/extproto` | Extension wire protocol |
4343 | `packages/agent/ext` | Public extension SDK |
4444 | `packages/agent/skills` | Skill discovery and loading |
45-| `packages/agent/swarm` | Background-agent state, supervision, and persistence |
45+| `packages/agent/subagents` | Background-agent state, supervision, and persistence |
4646 | `packages/agent/tools` | Built-in tools, permission checks, and jail behavior |
4747 | `packages/tui` | Terminal input, layout, rendering, markdown, themes, and images |
4848 | `packages/ignore` | Ignore-pattern matching |
M CONTRIBUTING.md +1 -1
@@ -46,7 +46,7 @@ The major ownership boundaries are:
4646 - `packages/agent/modes`: interactive and non-interactive user modes
4747 - `packages/agent/extensions`, `extproto`, and `ext`: extension hosting, protocol, and SDK
4848 - `packages/agent/skills`: reusable instruction discovery
49-- `packages/agent/swarm`: background-agent supervision and state
49+- `packages/agent/subagents`: background-agent supervision and state
5050 - `packages/agent/tools`: built-in tools, permissions, and jail checks
5151 - `packages/tui`: terminal mechanics and rendering
5252 - `packages/ignore`: ignore matching
M README.md +25 -27
@@ -290,7 +290,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
290290 | `/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`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. |
291291 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
292292 | `/btw` | Side chat with full context that doesn't add to the main thread. |
293-| `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). |
293+| `/subagents` | Spawn, monitor, and chat with background subagents. Each is a separate long-lived process with bounded lifecycle state, structured results, and optional Git worktree isolation. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). |
294294 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
295295 | `/compact` | Summarize the transcript into one message to free up context. |
296296 | `/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. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:packages/]`, `/study cmd/zot/main.go`. |
@@ -298,7 +298,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
298298 | `/unjail` | Allow tools to touch paths outside again. |
299299 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
300300 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
301-| `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-swarm, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |
301+| `/settings` | Change persistent settings, including inline images, terminal alerts, AI terminal titles, auto-subagents, fast mode, main/sub-agent LSP access, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; setting changes apply without a restart, while AI title generation waits for the first real prompt. |
302302 | `/clear` | Clear the chat transcript. |
303303 | `/exit` | Exit zot. |
304304
@@ -318,7 +318,7 @@ Four ops on the current session. `/session` alone opens a picker; each is also r
318318
319319 - **`/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.
320320
321- **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, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.
321+ **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/subagents` subagents are NOT included.** Their transcripts, unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to revive a unix socket on another box. If you want the conversation, copy it out of the dashboard manually.
322322 - **`/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` and `/jump`, and in the current-family tree when they are part of that family. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically).
323323 - **`/fork`** (also available as **`/session fork`**). Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies the transcript through that turn into a new branch and switches to it without starting a provider request. 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. Use a user-message row in `/session tree` when you want the prompt restored for editing, including image attachments.
324324 - **`/session tree`**. Shows the current session's family: its root ancestor and descendants, not unrelated root sessions in the same cwd. Use `up`/`down` and `enter` to choose a row; `esc` closes the tree. The picker keeps pre-compaction history available as forkable user and assistant messages, while hiding tool calls, tool results, compaction checkpoints, shell/image helper rows, and other internal context. Message rows are indented at their branch points and the current endpoint is tagged `[current]`. Empty and detached branch points are represented by visible boundary rows. A child whose historical fork point no longer matches after compaction stays discoverable as a detached branch under the parent's current tail rather than disappearing. Parentless sessions are roots; orphaned children (whose parent file was deleted) remain roots.
@@ -346,28 +346,27 @@ Each question runs an isolated agent turn against `system + main transcript + si
346346
347347 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.
348348
349-### `/swarm`
349+### `/subagents`
350350
351-Background subagents that run alongside your main session. Each one is a separate `zot` subprocess with its own model loop, its own persistent session file, and its own chat in the dashboard — but they all run in **the same working directory as the host**, so they see and edit the same files you do. Spawn one for a side task (“draft the migration”, “investigate this stack trace”, “write the test harness for module X”), keep going in the main thread, check in on it whenever you want.
351+Background subagents that run alongside your main session. Each one is a separate `zot` subprocess with its own model loop, persistent session file, versioned JSONL control protocol, structured turn result, and lifecycle state. Shared checkout mode preserves the historical behavior; worktree mode creates a temporary detached Git worktree, captures a patch and changed-file list, and never merges automatically.
352352
353-> **Agents edit the same files you do.** They use the same `read` / `write` / `edit` / `bash` tools as the main agent against the host's working directory. There's no per-agent worktree or branch. If you need parallel edits on isolated checkouts, set that up yourself with `git worktree` outside zot.
353+> **Choose the workspace deliberately.** Use `isolation:"worktree"` for parallel coding so children cannot edit the host checkout. Use shared mode for read-only review or explicitly coordinated work. Worktree isolation is an accident-prevention guardrail, not a security sandbox.
354354
355-```
356-/swarm # open the dashboard
357-/swarm new <task> # spawn an agent
358-/swarm new --agent reviewer <task> # use a named markdown profile
359-/swarm new --model gpt-5 <task> # pin the new agent to a specific model
360-/swarm new --reasoning high <task> # set the child reasoning level
361-/swarm logs <id> # jump straight into one agent's transcript
362-/swarm send <id> <text> # send a follow-up without opening the dashboard
363-/swarm resume # pick a stopped agent to bring back
364-/swarm resume <id> # bring a specific agent back
365-/swarm kill <id> # stop a running agent (its state stays)
366-/swarm remove <id> # delete the agent's session and state
367-/swarm list # alias for opening the dashboard
355+```text
356+/subagents # open the dashboard
357+/subagents new <task> # spawn an agent (shared mode)
358+/subagents new --agent reviewer <task> # use a named markdown profile
359+/subagents new --reasoning high <task> # set child reasoning
360+/subagents logs <id> # open one agent's transcript
361+/subagents send <id> <text> # send a follow-up turn
362+/subagents result <id> # show the structured result reference
363+/subagents resume-session <id> # continue without replaying the task
364+/subagents restart-task <id> # intentionally run the stored task again
365+/subagents kill <id> # cancel gracefully, then force-stop
366+/subagents remove <id> # delete durable state
368367 ```
369368
370-**Dashboard (`/swarm` with no arg)** — a list of every agent for the current session, with status, age, and current activity. Keys:
369+**Dashboard (`/subagents` with no arg)** — a list of every agent for the current session, with process/turn state, queue position, age, workspace mode, and current activity. Keys:
371370
372371 | Key | Action |
373372 |---|---|
@@ -386,17 +385,17 @@ Background subagents that run alongside your main session. Each one is a separat
386385
387386 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.
388387
389-**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 session and inbox socket, so the conversation continues from where it left off.
388+**Persistence across zot restarts** — every spawn writes a durable manifest, append-only event log, session file, and structured `result.json` under `$ZOT_HOME/subagents/agents/<id>/`. On the next `zot` launch they show up as **detached**; use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
390389
391-**Where state lives** — everything per-agent (session file, events log, inbox socket, meta) lives under `$ZOT_HOME/swarm/agents/<id>/`. The agent's actual code edits land directly in your repo; track them with normal `git status` / `git diff`.
390+**Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZOT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
392391
393392 **`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
394393
395-**Auto-swarm.** With `/settings` -> auto-swarm on, the main agent gets a built-in `swarm_spawn` tool and a system-prompt nudge to use it. It can then fork sub-agents on its own when a request naturally splits into independent parallel work ("implement A and B", "investigate three files"). If named markdown profiles are available, zot adds a compact `[subagents_list]` to the main prompt; the model can choose a profile with the tool's `agent` field and optionally override its `model`, `provider`, or `reasoning`. Each spawn returns the sub-agent id immediately and the main turn keeps going. When every sub-agent the agent spawned in that batch finishes its initial task, zot injects one `[auto-swarm update]` message back into the main chat recapping each agent's status, task, and complete final response. If an agent produced no assistant response, zot includes a truncated transcript tail for diagnostics instead. The main agent then writes a short follow-up summary referencing the agents by id. Off by default; toggle from `/settings`.
394+**Auto-subagents.** With `/settings` -> auto-subagents on, the main agent gets the canonical `subagent_spawn` tool and a system-prompt nudge to delegate independent work. The tool returns the child id and lifecycle state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and `isolation:"shared"` or `isolation:"worktree"`. Global concurrency and total-spawn budgets apply equally to slash commands, tool calls, and batches. Off by default; toggle from `/settings`.
396395
397396 ### Named subagent profiles
398397
399-zot discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default. With auto-swarm enabled, the main agent receives `[subagents_list]` metadata and can select a named profile through `swarm_spawn`. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples.
398+zot discovers the common markdown/frontmatter profile layout from `~/.agents/agents/*.md` by default. With auto-subagents enabled, the main agent receives `[subagents_list]` metadata and can select a named profile through `subagent_spawn`. Profile bodies are applied only to the selected child. See [docs/subagents.md](docs/subagents.md) for discovery, supported fields, reasoning levels, and examples.
400399
401400 ### `/settings`
402401
@@ -405,7 +404,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
405404 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
406405 - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.
407406 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zot: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
408-- **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` and [docs/subagents.md](docs/subagents.md) for details.
407+- **auto-subagents** — let the main agent spawn background sub-agents in parallel via the canonical `subagent_spawn` tool. Off by default. The tool accepts named profiles, reasoning, timeout, turn limits, and shared/worktree isolation; lifecycle and result references are persisted. Completion updates still arrive through the auto-subagents watcher. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.
409408 - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account.
410409 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
411410 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
@@ -1068,8 +1067,7 @@ packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter i
10681067 packages/agent/modes/telegram/ telegram adapter, api client, daemon
10691068 packages/agent/tools/ read, write, edit, bash, lsp, sandbox
10701069 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1071-packages/agent/subagents/ named markdown profile discovery and dispatch
1072-packages/agent/swarm/ background subagent runtime
1070+packages/agent/subagents/ named profiles, supervisor, and background runtime
10731071 packages/agent/sdk/ public Go SDK for embedding zot in-process (package sdk)
10741072 packages/agent/ext/ public Go SDK for writing extensions (package ext)
10751073 ```
M docs/lsp.md +1 -1
@@ -120,7 +120,7 @@ diagnostics allows a later recurrence to be surfaced again.
120120
121121 - **lsp in main session** (`lsp_enabled`) controls the main agent and applies
122122 immediately to the live tool registry.
123-- **lsp in sub-agents** (`subagent_lsp_enabled`) controls newly spawned swarm
123+- **lsp in sub-agents** (`subagent_lsp_enabled`) controls newly spawned subagent
124124 child processes.
125125
126126 The sub-agent setting does not grant access to a child whose profile or
M docs/providers.md +1 -1
@@ -123,7 +123,7 @@ off by default and is stored as `fast_mode` in `$ZOT_HOME/config.json`.
123123
124124 Fast mode is currently limited to those OpenAI provider IDs. Enabling it while
125125 using another provider returns an error instead of silently changing that
126-provider's request. Swarm children inherit the setting from their parent.
126+provider's request. Subagent children inherit the setting from their parent.
127127
128128 Example:
129129
M docs/subagents.md +27 -6
@@ -39,7 +39,7 @@ Supported metadata:
3939
4040 | Field | Meaning |
4141 |---|---|
42-| `name` | Name passed to `swarm_spawn`'s `agent` field. Falls back to the filename. |
42+| `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. |
4343 | `description` | Short description shown to the main agent. |
4444 | `tools` | Comma-separated or list-form tool names. zot enforces its built-in `read`, `write`, `edit`, `bash`, and `lsp` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. |
4545 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |
@@ -50,11 +50,11 @@ Supported metadata:
5050 | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. |
5151 | `fastMode` | Optional fast-mode restriction. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` only permits fast mode when the host setting is enabled. |
5252
53-Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot.
53+Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. Recursive child spawning is disabled in v1; a worker cannot invoke `subagent_spawn` to create descendants.
5454
5555 ## Selecting a profile
5656
57-When **auto-swarm** is enabled in `/settings`, the main agent receives a compact `[subagents_list]` section in its system prompt and can select a profile:
57+When **auto-subagents** is enabled in `/settings`, the main agent receives a compact `[subagents_list]` section in its system prompt and can select a profile for `subagent_spawn`:
5858
5959 ```json
6060 {
@@ -80,8 +80,29 @@ A per-spawn reasoning override is also accepted:
8080 The interactive command also supports the same selection explicitly:
8181
8282 ```text
83-/swarm new --agent reviewer Review the authentication package
84-/swarm new --agent implementer --reasoning high Implement the parser change
83+/subagents new --agent reviewer Review the authentication package
84+/subagents new --agent implementer --reasoning high Implement the parser change
8585 ```
8686
87-All swarm children still share the host working directory. Named profiles change the child's instructions and configuration; they do not create a worktree, branch, or security sandbox. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting.
87+Shared mode preserves the historical host working directory. For parallel coding, pass `isolation:"worktree"` to `subagent_spawn`; zot creates a detached Git worktree, captures changed files and a patch, and never merges automatically. Named profiles change the child's instructions and configuration; they are not a security sandbox. Child credentials are transferred over stdin rather than argv or persisted metadata, and the active provider endpoint/TLS setting is inherited only when the child uses that provider. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting.
88+
89+## Lifecycle, results, and recovery
90+
91+Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, and logical result references.
92+
93+The worker and supervisor communicate over newline-delimited JSON. Current messages use version `1` envelopes with `version`, `message_id`, `agent_id`, `turn_id`, `timestamp`, and `payload`. Unknown event names and payload fields are retained. Only versioned JSONL envelopes are accepted on the worker protocol.
94+
95+A completed worker emits a `turn.result` event and writes `result.json`. The inline output is bounded; inspect the full session through the stable references:
96+
97+```text
98+subagent://<id>
99+subagent://<id>/history
100+subagent://<id>/result
101+subagent://<id>/patch
102+```
103+
104+Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
105+
106+## Resource policy
107+
108+The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `max_total_spawned`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zot run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary.
M packages/agent/args.go +38 -19
@@ -20,13 +20,13 @@ const (
2020 ModeStream Mode = "stream"
2121 ModeJSON Mode = "json"
2222 ModeRPC Mode = "rpc"
23- // ModeSwarmAgent is the long-lived, headless daemon mode used by
24- // swarm-spawned agents. The binary opens a unix-socket inbox at
25- // the path provided by --swarm-agent, reads supervisor messages
26- // off it ("user ...", "cancel", "shutdown"), runs each user turn
27- // against a persistent session, and streams JSONL events on
28- // stdout. See packages/agent/swarm/inbox.go for the wire protocol.
29- ModeSwarmAgent Mode = "swarm-agent"
23+ // ModeSubagentWorker is the long-lived, headless daemon mode used by
24+ // subagent-spawned agents. The binary opens a unix-socket inbox at
25+ // the path provided by --subagent-worker, reads versioned JSONL
26+ // supervisor messages, runs each user turn against a persistent session,
27+ // and streams JSONL events on
28+ // stdout.
29+ ModeSubagentWorker Mode = "subagent-worker"
3030 )
3131
3232 // Args holds parsed command-line options.
@@ -36,7 +36,7 @@ type Args struct {
3636 Model string
3737 APIKey string
3838
39- // inheritedCredential is populated only by swarm-agent mode from
39+ // inheritedCredential is populated only by subagent-worker mode from
4040 // the supervisor's stdin. It is never accepted as a CLI argument.
4141 inheritedCredential string
4242 inheritedAuthMethod string
@@ -48,11 +48,11 @@ type Args struct {
4848 Reasoning string
4949 Temperature *float32
5050
51- // FastMode is an internal swarm-child propagation flag. Normal users
51+ // FastMode is an internal subagent-child propagation flag. Normal users
5252 // enable fast mode through the persisted config /settings toggle.
5353 FastMode bool
5454 // FastModeSet distinguishes an explicit child override (including
55- // --no-fast-mode) from an omitted flag so persisted swarm settings
55+ // --no-fast-mode) from an omitted flag so persisted subagent settings
5656 // survive a parent config change.
5757 FastModeSet bool
5858
@@ -123,13 +123,16 @@ type Args struct {
123123 // mode auto-submits it once at startup before InitialInput handling.
124124 StartupPre string
125125
126- // SwarmAgent is the inbox-socket path when this process is a
127- // swarm-spawned agent. Empty in every other mode. Set by
128- // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent.
129- SwarmAgent string
126+ // SubagentWorker is the inbox-socket path when this process is a
127+ // subagent worker. Empty in every other mode. Set by either
128+ // --subagent-worker.
129+ SubagentWorker string
130130
131- // Subagent selects a named markdown profile for a swarm child.
132- // It is intentionally an internal child flag; the parent swarm tool
131+ // SubagentMaxTurns limits prompt-level turns in worker mode.
132+ SubagentMaxTurns int
133+
134+ // Subagent selects a named markdown profile for a subagent child.
135+ // It is intentionally an internal child flag; the parent subagent tool
133136 // passes only the profile name and the child discovers the definition
134137 // locally.
135138 Subagent string
@@ -158,6 +161,12 @@ func ParseArgs(in []string) (Args, error) {
158161 for i := 0; i < len(in); i++ {
159162 arg := in[i]
160163 switch arg {
164+ case "--":
165+ // Everything after the terminator is prompt text, including
166+ // values that begin with a dash. This is used by subagent
167+ // workers and is also a conventional CLI escape hatch.
168+ positional = append(positional, in[i+1:]...)
169+ i = len(in)
161170 case "-h", "--help":
162171 a.Help = true
163172 case "-v", "--version":
@@ -289,13 +298,13 @@ func ParseArgs(in []string) (Args, error) {
289298 return a, err
290299 }
291300 a.CWD = v
292- case "--swarm-agent":
301+ case "--subagent-worker":
293302 v, err := want(&i, arg)
294303 if err != nil {
295304 return a, err
296305 }
297- a.SwarmAgent = v
298- a.Mode = ModeSwarmAgent
306+ a.SubagentWorker = v
307+ a.Mode = ModeSubagentWorker
299308 case "--subagent":
300309 v, err := want(&i, arg)
301310 if err != nil {
@@ -323,6 +332,16 @@ func ParseArgs(in []string) (Args, error) {
323332 return a, fmt.Errorf("--max-steps must be a positive integer")
324333 }
325334 a.MaxSteps = n
335+ case "--max-turns":
336+ v, err := want(&i, arg)
337+ if err != nil {
338+ return a, err
339+ }
340+ var n int
341+ if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n <= 0 {
342+ return a, fmt.Errorf("--max-turns must be a positive integer")
343+ }
344+ a.SubagentMaxTurns = n
326345 default:
327346 if strings.HasPrefix(arg, "-") && arg != "-" {
328347 return a, fmt.Errorf("unknown flag %q", arg)
M packages/agent/args_test.go +12 -2
@@ -3,11 +3,11 @@ package agent
33 import "testing"
44
55 func TestParseArgsSubagentAndReasoning(t *testing.T) {
6- args, err := ParseArgs([]string{"--swarm-agent", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"})
6+ args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--subagent", "reviewer", "--reasoning", "high", "task"})
77 if err != nil {
88 t.Fatal(err)
99 }
10- if args.Mode != ModeSwarmAgent || args.Subagent != "reviewer" || args.Reasoning != "high" || args.Prompt != "task" {
10+ if args.Mode != ModeSubagentWorker || args.Subagent != "reviewer" || args.Reasoning != "high" || args.Prompt != "task" {
1111 t.Fatalf("parsed args = mode=%q subagent=%q reasoning=%q prompt=%q", args.Mode, args.Subagent, args.Reasoning, args.Prompt)
1212 }
1313 }
@@ -22,6 +22,16 @@ func TestParseArgsNoLSP(t *testing.T) {
2222 }
2323 }
2424
25+func TestParseArgsAllowsLeadingDashPromptAfterTerminator(t *testing.T) {
26+ args, err := ParseArgs([]string{"--subagent-worker", "/tmp/in.sock", "--", "--inspect", "auth flow"})
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ if args.Prompt != "--inspect auth flow" {
31+ t.Fatalf("prompt = %q, want leading-dash task preserved", args.Prompt)
32+ }
33+}
34+
2535 func TestParseArgsTemperatureAllowsZero(t *testing.T) {
2636 args, err := ParseArgs([]string{"--temperature", "0"})
2737 if err != nil {
M packages/agent/build.go +18 -3
@@ -691,7 +691,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
691691 if args.PermissionSet != nil {
692692 sandbox.SetPermissions(args.PermissionSet)
693693 }
694- subagentSession := args.Mode == ModeSwarmAgent || strings.TrimSpace(args.Subagent) != ""
694+ subagentSession := args.Mode == ModeSubagentWorker || strings.TrimSpace(args.Subagent) != ""
695695 lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(subagentSession)
696696 reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(subagentSession), cfg.LSPDiagnosticsOnEditEnabled(subagentSession))
697697
@@ -737,13 +737,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
737737 append_ = append(append_, skillAddendum)
738738 }
739739 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive
740- if selectedProfile == nil && interactiveMode && cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
740+ if selectedProfile == nil && interactiveMode && autoSubagentsToolAllowed(args) && cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
741741 homeDir, _ := os.UserHomeDir()
742742 profiles, _ := subagents.Discover(args.CWD, homeDir)
743743 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
744744 append_ = append(append_, subagentsAddendum)
745745 }
746- append_ = append(append_, AutoSwarmSystemAddendum)
746+ append_ = append(append_, AutoSubagentsSystemAddendum)
747747 }
748748 if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
749749 append_ = append(append_, selectedProfile.SystemPrompt)
@@ -1188,6 +1188,21 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo
11881188 return false
11891189 }
11901190
1191+func autoSubagentsToolAllowed(args Args) bool {
1192+ if args.NoTools || args.PermissionSet != nil {
1193+ return false
1194+ }
1195+ if len(args.Tools) == 0 {
1196+ return true
1197+ }
1198+ for _, name := range args.Tools {
1199+ if name == "subagent_spawn" {
1200+ return true
1201+ }
1202+ }
1203+ return false
1204+}
1205+
11911206 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
11921207 order := []string{"read", "write", "edit", "bash", "lsp"}
11931208 var out []ToolSummary
M packages/agent/build_test.go +3 -3
@@ -156,7 +156,7 @@ func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
156156 }
157157 }
158158
159-func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) {
159+func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) {
160160 root := t.TempDir()
161161 home := filepath.Join(root, "home")
162162 zotHome := filepath.Join(root, "zot-home")
@@ -171,7 +171,7 @@ func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) {
171171 t.Setenv("ZOT_HOME", zotHome)
172172 t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
173173 enabled := true
174- if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSwarmEnabled: &enabled}); err != nil {
174+ if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &enabled}); err != nil {
175175 t.Fatal(err)
176176 }
177177 profile := `---
@@ -510,7 +510,7 @@ func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
510510 }
511511 }
512512
513-func TestResolveUsesInheritedSwarmCredential(t *testing.T) {
513+func TestResolveUsesInheritedSupervisorCredential(t *testing.T) {
514514 t.Setenv("ZOT_HOME", t.TempDir())
515515 t.Setenv("OPENAI_API_KEY", "")
516516
M packages/agent/cli.go +194 -112
@@ -21,7 +21,6 @@ import (
2121 "github.com/patriceckhart/zot/packages/agent/modes"
2222 "github.com/patriceckhart/zot/packages/agent/skills"
2323 "github.com/patriceckhart/zot/packages/agent/subagents"
24- "github.com/patriceckhart/zot/packages/agent/swarm"
2524 "github.com/patriceckhart/zot/packages/agent/tools"
2625 "github.com/patriceckhart/zot/packages/core"
2726 "github.com/patriceckhart/zot/packages/provider"
@@ -737,8 +736,8 @@ func runWithArgs(args Args, version string) error {
737736 return runJSONMode(ctx, args, version)
738737 case ModeRPC:
739738 return runRPCMode(ctx, args, version)
740- case ModeSwarmAgent:
741- return runSwarmAgentMode(ctx, args, version)
739+ case ModeSubagentWorker:
740+ return runSubagentWorkerMode(ctx, args, version)
742741 default:
743742 return runInteractive(ctx, args, version)
744743 }
@@ -1142,7 +1141,7 @@ func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.S
11421141 // skills and currently loaded extension tools) and updates the live
11431142 // agent's registry and system prompt. Used after /reload-ext and after
11441143 // zotfile entry.pre installs new skills or extensions.
1145-// mutateRegistry, if non-nil, can inject session-specific tools (e.g. swarm_spawn).
1144+// mutateRegistry, if non-nil, can inject session-specific tools (e.g. subagent_spawn).
11461145 func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) {
11471146 if ag == nil {
11481147 return
@@ -1183,7 +1182,37 @@ func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *exte
11831182
11841183 // ---- interactive mode: opens the TUI even without credentials ----
11851184
1185+func subagentPolicyFromConfig(cfg SubagentsConfig) subagents.SubagentPolicy {
1186+ parseDuration := func(value string) time.Duration {
1187+ if strings.TrimSpace(value) == "" {
1188+ return 0
1189+ }
1190+ parsed, err := time.ParseDuration(strings.TrimSpace(value))
1191+ if err != nil || parsed <= 0 {
1192+ return 0
1193+ }
1194+ return parsed
1195+ }
1196+ return subagents.SubagentPolicy{
1197+ MaxConcurrent: cfg.MaxConcurrent,
1198+ MaxConcurrentPerParent: cfg.MaxConcurrentPerParent,
1199+ MaxTotalSpawned: cfg.MaxTotalSpawned,
1200+ QueueTimeout: parseDuration(cfg.QueueTimeout),
1201+ DefaultTimeout: parseDuration(cfg.DefaultTimeout),
1202+ MaxTurns: cfg.MaxTurns,
1203+ MaxOutputBytes: cfg.MaxOutputBytes,
1204+ MaxOutputLines: cfg.MaxOutputLines,
1205+ AllowedTools: append([]string(nil), cfg.AllowedTools...),
1206+ AllowedRoots: append([]string(nil), cfg.AllowedRoots...),
1207+ HeartbeatInterval: parseDuration(cfg.HeartbeatInterval),
1208+ IdleTimeout: parseDuration(cfg.IdleTimeout),
1209+ ReconnectTimeout: parseDuration(cfg.ReconnectTimeout),
1210+ CancelGracePeriod: parseDuration(cfg.CancelGracePeriod),
1211+ }
1212+}
1213+
11861214 func runInteractive(ctx context.Context, args Args, version string) error {
1215+ initialCfg, _ := LoadConfig()
11871216 // Resolve WITHOUT requiring credentials.
11881217 r, err := Resolve(args, false)
11891218 if err != nil {
@@ -1201,7 +1230,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12011230
12021231 // persistMu guards the active session and selection across the agent
12031232 // loop, TUI callbacks, and session swaps. Declare it before tool
1204- // construction because swarm defaults must read the live selection too.
1233+ // construction because subagent defaults must read the live selection too.
12051234 var persistMu sync.Mutex
12061235 // Serialize session transitions with persistence callbacks so a candidate
12071236 // is read only after the old agent is flushed and cannot be overwritten by
@@ -1245,70 +1274,99 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12451274 extToolAdapter := &extToolAdapter{mgr: extMgr}
12461275 r.MergeExtensionTools(extToolAdapter)
12471276
1248- // Build the swarm supervisor BEFORE the agent so the auto-swarm
1277+ // Build the subagent supervisor BEFORE the agent so the auto-subagents
12491278 // tool can reference it during tool-registry construction. State
1250- // lives under ZotHome/swarm so per-agent meta/events survive
1279+ // lives under ZotHome/subagents so per-agent meta/events survive
12511280 // restarts; the user can hunt orphaned agents down with
12521281 // `git worktree list` if anything misbehaves.
12531282 //
1254- // swarmMgr is also captured by loadSession / changeCWD closures
1283+ // subagentsMgr is also captured by loadSession / changeCWD closures
12551284 // further down the function, which is why we keep the variable
12561285 // in this outer scope rather than scoping it tighter.
1257- var swarmMgr *swarm.Swarm
1258- swarmMgr = swarm.New(swarm.Config{
1259- Root: filepath.Join(ZotHome(), "swarm"),
1260- RepoRoot: r.CWD,
1261- FastMode: r.FastMode,
1262- ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) {
1263- if providerID == "ollama" {
1264- return swarm.Credential{Value: "ollama", Method: "apikey"}, nil
1286+ var subagentsMgr *subagents.Supervisor
1287+ subagentsMgr = subagents.New(subagents.Config{
1288+ Context: ctx,
1289+ Root: filepath.Join(ZotHome(), "subagents"),
1290+ RepoRoot: r.CWD,
1291+ Provider: r.Provider,
1292+ FastMode: r.FastMode,
1293+ BaseURL: r.BaseURL,
1294+ InsecureTLS: r.InsecureTLS,
1295+ Policy: subagentPolicyFromConfig(initialCfg.Subagents),
1296+ // Keep an explicit launch key in this callback only. Supervisor persists
1297+ // connection settings, but never stores credentials in Agent metadata
1298+ // or puts them in the worker's argv/environment.
1299+ ResolveCredential: func(ctx context.Context, providerID string) (subagents.Credential, error) {
1300+ childProvider := canonicalProvider(providerID)
1301+ persistMu.Lock()
1302+ hostProvider := canonicalProvider(activeProvider)
1303+ persistMu.Unlock()
1304+ if childProvider == "" {
1305+ childProvider = hostProvider
1306+ }
1307+ explicit := ""
1308+ // A launch-time API key is scoped to the provider selected at
1309+ // startup. Never send it to an explicitly different child
1310+ // provider; resolve that provider's own credential instead.
1311+ if args.APIKey != "" && childProvider == hostProvider {
1312+ explicit = args.APIKey
1313+ }
1314+ if childProvider == "ollama" && explicit == "" {
1315+ return subagents.Credential{Value: "ollama", Method: "apikey"}, nil
12651316 }
1266- credential, method, accountID, err := ResolveCredentialFullContext(ctx, providerID, "")
1317+ credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
12671318 if err != nil {
12681319 // Providers backed only by a local endpoint do not need a key.
12691320 // Leave stdin untouched and let the child resolve that endpoint.
1270- if !CredentialAvailable(providerID) {
1271- return swarm.Credential{}, nil
1321+ if !CredentialAvailable(childProvider) {
1322+ return subagents.Credential{}, nil
12721323 }
1273- return swarm.Credential{}, err
1324+ return subagents.Credential{}, err
12741325 }
1275- return swarm.Credential{Value: credential, Method: method, AccountID: accountID}, nil
1326+ return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
12761327 },
12771328 })
12781329 // Pull any previously-spawned agents off disk so the dashboard
12791330 // shows them as detached and the user can resume / remove them.
1280- _, _ = swarmMgr.Reload()
1331+ _, _ = subagentsMgr.Reload()
1332+ if args.PermissionSet != nil {
1333+ // Packaged zotfile agents have an explicit capability ceiling. A
1334+ // child process would otherwise rebuild a fresh unrestricted sandbox,
1335+ // so do not expose either slash or tool-based delegation from this
1336+ // restricted host until worker capability propagation exists.
1337+ subagentsMgr = nil
1338+ }
12811339
1282- // onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool
1340+ // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool
12831341 // fires after every successful spawn. It hands the agent off to
12841342 // the running Interactive so the watcher can flush a summary back
12851343 // into chat when all sub-agents finish. Reads `iv` lazily because
12861344 // the Interactive is constructed after the agent.
1287- onSpawnedSwarm := func(a *swarm.Agent, task string) {
1345+ onSpawnedSupervisor := func(a *subagents.Agent, task string) {
12881346 if iv != nil {
1289- iv.TrackSwarmAgent(a, task)
1347+ iv.TrackSubagentWorker(a, task)
12901348 }
12911349 }
12921350 resolveSubagent := func(name string) (*subagents.Profile, error) {
12931351 return findSubagentProfile(r.CWD, name)
12941352 }
12951353
1296- // Inject the swarm_spawn auto-swarm tool only when /settings ->
1297- // auto-swarm is currently enabled. Registering it unconditionally
1354+ // Inject the subagent_spawn auto-subagents tool only when /settings ->
1355+ // auto-subagents is currently enabled. Registering it unconditionally
12981356 // leaves the model trying to call it (and getting a polite error)
12991357 // even when the user has switched the feature off. The /settings
13001358 // toggle live-mutates the running agent's registry separately so
13011359 // flipping the flag mid-session takes effect on the next turn.
1302- injectSwarmSpawn := func(reg core.Registry) core.Registry {
1303- if reg == nil {
1360+ injectSubagentSpawn := func(reg core.Registry) core.Registry {
1361+ if reg == nil || subagentsMgr == nil || !autoSubagentsToolAllowed(args) {
13041362 return reg
13051363 }
1306- if !AutoSwarmEnabled() {
1364+ if !AutoSubagentsEnabled() {
13071365 return reg
13081366 }
1309- reg["swarm_spawn"] = &tools.SwarmSpawnTool{
1310- Swarm: swarmMgr,
1311- Enabled: AutoSwarmEnabled,
1367+ canonical := &tools.SubagentSpawnTool{
1368+ Supervisor: subagentsMgr,
1369+ Enabled: AutoSubagentsEnabled,
13121370 DefaultModel: func() string {
13131371 persistMu.Lock()
13141372 defer persistMu.Unlock()
@@ -1321,11 +1379,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13211379 },
13221380 DefaultReasoning: func() string { return r.Reasoning },
13231381 ResolveSubagent: resolveSubagent,
1324- OnSpawned: onSpawnedSwarm,
1382+ OnSpawned: onSpawnedSupervisor,
13251383 }
1384+ reg[canonical.Name()] = canonical
13261385 return reg
13271386 }
1328- injectSwarmSpawn(r.ToolRegistry)
1387+ injectSubagentSpawn(r.ToolRegistry)
13291388
13301389 // Confirmation gate: when --no-yolo is on, the agent must ask
13311390 // the user before every tool call. In interactive mode the TUI
@@ -1407,8 +1466,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14071466 return nil, "", "", err
14081467 }
14091468 resolved.UseSandbox(sharedSandbox)
1469+ if subagentsMgr != nil {
1470+ subagentsMgr.SetProvider(resolved.Provider)
1471+ subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1472+ }
14101473 resolved.MergeExtensionTools(extToolAdapter)
1411- injectSwarmSpawn(resolved.ToolRegistry)
1474+ injectSubagentSpawn(resolved.ToolRegistry)
14121475 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
14131476 }
14141477
@@ -1426,8 +1489,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14261489 return nil, "", "", err
14271490 }
14281491 resolved.UseSandbox(sharedSandbox)
1492+ if subagentsMgr != nil {
1493+ subagentsMgr.SetProvider(resolved.Provider)
1494+ subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1495+ }
14291496 resolved.MergeExtensionTools(extToolAdapter)
1430- injectSwarmSpawn(resolved.ToolRegistry)
1497+ injectSubagentSpawn(resolved.ToolRegistry)
14311498 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
14321499 }
14331500
@@ -1453,8 +1520,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14531520 return nil, "", "", err
14541521 }
14551522 resolved.UseSandbox(sharedSandbox)
1523+ if subagentsMgr != nil {
1524+ subagentsMgr.SetProvider(resolved.Provider)
1525+ subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1526+ }
14561527 resolved.MergeExtensionTools(extToolAdapter)
1457- injectSwarmSpawn(resolved.ToolRegistry)
1528+ injectSubagentSpawn(resolved.ToolRegistry)
14581529 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
14591530 }
14601531
@@ -1473,7 +1544,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14731544 if current == nil {
14741545 return
14751546 }
1476- refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1547+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn)
14771548 })
14781549
14791550 var sess *core.Session
@@ -1696,10 +1767,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
16961767 }
16971768 activeProvider = candidate.provider
16981769 activeModel = candidate.model
1699- if swarmMgr != nil && candidate.session != nil {
1770+ if subagentsMgr != nil && candidate.session != nil {
17001771 // Keep the dashboard scope in the same commit as the session,
17011772 // agent, usage, and persistence baseline.
1702- swarmMgr.SetActiveSession(candidate.session.ID)
1773+ subagentsMgr.SetActiveSession(candidate.session.ID)
17031774 }
17041775 committed = true
17051776 persistMu.Unlock()
@@ -1718,7 +1789,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17181789 //
17191790 // Steps, in order:
17201791 // 1. resolve + validate the new path (~ expansion, abs/rel)
1721- // 2. stage the candidate cwd, sandbox, and swarm state
1792+ // 2. stage the candidate cwd, sandbox, and subagent state
17221793 // 3. rebuild the agent and allocate its new session
17231794 // 4. commit all new state together, closing the old session last
17241795 // 5. push the committed state into the running Interactive
@@ -1765,7 +1836,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17651836
17661837 // Stage the candidate state while retaining a complete rollback
17671838 // snapshot. A failed rebuild or session allocation must leave the
1768- // current agent, session, sandbox, and swarm root usable.
1839+ // current agent, session, sandbox, and subagent root usable.
17691840 oldCWD := args.CWD
17701841 oldResolvedCWD := r.CWD
17711842 oldPermissionSet := args.PermissionSet
@@ -1773,9 +1844,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17731844 oldActiveProvider := activeProvider
17741845 oldActiveModel := activeModel
17751846 persistMu.Unlock()
1776- oldSwarmRoot := ""
1777- if swarmMgr != nil {
1778- oldSwarmRoot = swarmMgr.RepoRoot()
1847+ oldSupervisorRoot := ""
1848+ if subagentsMgr != nil {
1849+ oldSupervisorRoot = subagentsMgr.RepoRoot()
17791850 }
17801851 oldSandboxRoot := ""
17811852 oldSandboxLocked := false
@@ -1791,8 +1862,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17911862 activeProvider = oldActiveProvider
17921863 activeModel = oldActiveModel
17931864 persistMu.Unlock()
1794- if swarmMgr != nil {
1795- swarmMgr.SetRepoRoot(oldSwarmRoot)
1865+ if subagentsMgr != nil {
1866+ subagentsMgr.SetRepoRoot(oldSupervisorRoot)
17961867 }
17971868 if sharedSandbox != nil {
17981869 sharedSandbox.Root = oldSandboxRoot
@@ -1806,8 +1877,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
18061877
18071878 args.CWD = absPath
18081879 r.CWD = absPath
1809- if swarmMgr != nil {
1810- swarmMgr.SetRepoRoot(absPath)
1880+ if subagentsMgr != nil {
1881+ subagentsMgr.SetRepoRoot(absPath)
18111882 }
18121883 if args.PermissionSet != nil {
18131884 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
@@ -1880,9 +1951,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
18801951 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
18811952 }
18821953
1883- // Re-scope the swarm dashboard to the new session.
1884- if swarmMgr != nil && newSess != nil {
1885- swarmMgr.SetActiveSession(newSess.ID)
1954+ // Re-scope the subagent dashboard to the new session.
1955+ if subagentsMgr != nil && newSess != nil {
1956+ subagentsMgr.SetActiveSession(newSess.ID)
18861957 }
18871958 if newSess != nil {
18881959 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
@@ -1943,7 +2014,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19432014 }()
19442015 }
19452016
1946- initialCfg, _ := LoadConfig()
19472017 fastMode := r.FastMode
19482018 quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))
19492019 for idx, s := range initialCfg.QuickModelShortcuts {
@@ -1958,18 +2028,20 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19582028 }
19592029 }
19602030
1961- // swarmMgr was constructed and reloaded earlier (before the agent
1962- // build, so the auto-swarm tool could capture it). Here we just
1963- // scope the dashboard to the active host session so /swarm only
2031+ // subagentsMgr was constructed and reloaded earlier (before the agent
2032+ // build, so the auto-subagents tool could capture it). Here we just
2033+ // scope the dashboard to the active host session so /subagents only
19642034 // shows agents this session spawned (and any pre-upgrade unscoped
19652035 // agents — see SnapshotAll docs). Updated again whenever the
19662036 // user swaps sessions via loadSession below.
1967- if sess != nil {
1968- swarmMgr.SetActiveSession(sess.ID)
2037+ if sess != nil && subagentsMgr != nil {
2038+ subagentsMgr.SetActiveSession(sess.ID)
19692039 }
19702040 // Best-effort shutdown on interactive exit: stop all running
19712041 // agents so they don't outlive their parent zot.
1972- defer swarmMgr.StopAll()
2042+ if subagentsMgr != nil {
2043+ defer subagentsMgr.StopAll()
2044+ }
19732045
19742046 var startupSkills []*skills.Skill
19752047 if r.SkillTool != nil {
@@ -1978,54 +2050,56 @@ func runInteractive(ctx context.Context, args Args, version string) error {
19782050 homeDir, _ := os.UserHomeDir()
19792051 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
19802052 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2053+ autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
19812054
19822055 iv = modes.NewInteractive(modes.InteractiveConfig{
1983- Terminal: term,
1984- Theme: theme,
1985- InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1986- TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
1987- TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
1988- AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1989- FastMode: &fastMode,
1990- LSPEnabled: initialCfg.LSPEnabled,
1991- SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
1992- AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1993- JailByDefault: initialCfg.JailByDefault,
1994- QuickModelShortcuts: quickModelShortcuts,
1995- RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
1996- RespectGitignore: initialCfg.RespectGitignore,
1997- CompactMode: initialCfg.CompactMode,
1998- TUIInputStyle: initialCfg.TUIInputStyle,
1999- TUIStatusPosition: initialCfg.TUIStatusPosition,
2000- TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2001- ThemeName: initialCfg.Theme,
2002- FlatTools: initialCfg.FlatToolRender(),
2003- CompactUser: initialCfg.CompactUserInput(),
2004- ExtensionThemes: extMgr.ThemeOptions,
2005- AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
2006- SubagentsSystemAddendum: subagentsAddendum,
2007- SettingsStore: configSettingsStore{},
2008- Model: r.Model,
2009- Provider: r.Provider,
2010- AuthMethod: r.AuthMethod,
2011- BaseURL: r.BaseURL,
2012- Reasoning: r.Reasoning,
2013- SystemPrompt: r.SystemPrompt,
2014- Tools: r.ToolRegistry,
2015- MaxSteps: r.MaxSteps,
2016- CWD: r.CWD,
2017- StartupAgentName: args.AgentName,
2018- StartupContextPaths: instructionContextPaths(r.ContextFiles),
2019- StartupExtensionNames: startupExtensionNames(extMgr.All()),
2020- StartupExtensionErrors: startupExtensionErrors,
2021- StartupSkillNames: startupSkillNames(startupSkills),
2022- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2023- ZotHome: ZotHome(),
2024- SessionsRoot: agentSessionsRoot(ZotHome(), args),
2025- Version: version,
2026- UpdateInfoChan: updateCh,
2027- Sandbox: sharedSandbox,
2028- Agent: ag,
2056+ Terminal: term,
2057+ Theme: theme,
2058+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
2059+ TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
2060+ TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
2061+ AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
2062+ AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2063+ FastMode: &fastMode,
2064+ LSPEnabled: initialCfg.LSPEnabled,
2065+ SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
2066+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
2067+ JailByDefault: initialCfg.JailByDefault,
2068+ QuickModelShortcuts: quickModelShortcuts,
2069+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
2070+ RespectGitignore: initialCfg.RespectGitignore,
2071+ CompactMode: initialCfg.CompactMode,
2072+ TUIInputStyle: initialCfg.TUIInputStyle,
2073+ TUIStatusPosition: initialCfg.TUIStatusPosition,
2074+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2075+ ThemeName: initialCfg.Theme,
2076+ FlatTools: initialCfg.FlatToolRender(),
2077+ CompactUser: initialCfg.CompactUserInput(),
2078+ ExtensionThemes: extMgr.ThemeOptions,
2079+ AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendum,
2080+ SubagentsSystemAddendum: subagentsAddendum,
2081+ SettingsStore: configSettingsStore{},
2082+ Model: r.Model,
2083+ Provider: r.Provider,
2084+ AuthMethod: r.AuthMethod,
2085+ BaseURL: r.BaseURL,
2086+ Reasoning: r.Reasoning,
2087+ SystemPrompt: r.SystemPrompt,
2088+ Tools: r.ToolRegistry,
2089+ MaxSteps: r.MaxSteps,
2090+ CWD: r.CWD,
2091+ StartupAgentName: args.AgentName,
2092+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
2093+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
2094+ StartupExtensionErrors: startupExtensionErrors,
2095+ StartupSkillNames: startupSkillNames(startupSkills),
2096+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2097+ ZotHome: ZotHome(),
2098+ SessionsRoot: agentSessionsRoot(ZotHome(), args),
2099+ Version: version,
2100+ UpdateInfoChan: updateCh,
2101+ Sandbox: sharedSandbox,
2102+ Agent: ag,
20292103 InitialSessionTitle: func() string {
20302104 if sess == nil || sessionTitlePending {
20312105 return ""
@@ -2043,12 +2117,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
20432117 _ = extMgr.Reload(context.Background(), 2*time.Second)
20442118 }
20452119 if current != nil {
2046- refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
2120+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn)
20472121 }
20482122 },
20492123 RefreshTools: func() {
20502124 current := liveInteractiveAgent(iv, ag)
2051- refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
2125+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn)
20522126 },
20532127 AuthManager: mgr,
20542128 LlamaCPPConfig: ResolveLlamaCPPConfig,
@@ -2114,7 +2188,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
21142188 },
21152189 SessionTransition: newSessionTransition(&sessionTransitionMu),
21162190 Extensions: extMgr,
2117- Swarm: swarmMgr,
2191+ Supervisor: subagentsMgr,
21182192 ResolveSubagent: resolveSubagent,
21192193 ChangelogChan: changelogCh,
21202194 OnChangelogDismiss: func() {
@@ -2238,9 +2312,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
22382312 }
22392313 closeAgentLSP(finalAg)
22402314 closeResolvedLSP(r)
2315+ // Stop supervised workers before exiting. os.Exit skips deferred
2316+ // cleanup, so relying only on the normal runInteractive defer would
2317+ // leave child daemons (and their descendants) alive after a TERM or
2318+ // HUP delivered to the host.
2319+ if subagentsMgr != nil {
2320+ subagentsMgr.StopAll()
2321+ }
22412322 // Exit cleanly. Re-raising the signal would skip os.Exit's
22422323 // at-exit hooks; explicit exit is fine because we've already
2243- // flushed the only at-risk state (the session file).
2324+ // flushed the only at-risk state (the session file) and stopped
2325+ // supervised workers.
22442326 os.Exit(0)
22452327 }()
22462328
@@ -2284,10 +2366,10 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
22842366 switch {
22852367 case args.Session != "":
22862368 s, msgs, err = core.OpenSession(args.Session)
2287- // The swarm-agent child passes a fixed --session path that
2369+ // The subagent-worker child passes a fixed --session path that
22882370 // may not exist yet on first Spawn. Treat ENOENT as "create
22892371 // a fresh session AT THIS PATH" so the conversation actually
2290- // gets persisted; without this fallback the swarm child runs
2372+ // gets persisted; without this fallback the subagent child runs
22912373 // with sess==nil and every Resume re-starts with no memory
22922374 // of the prior turns. Other openers (--continue / --resume /
22932375 // the picker) never see ENOENT here because they only choose
M packages/agent/config.go +27 -5
@@ -22,6 +22,25 @@ type QuickModelShortcut struct {
2222 Model string `json:"model"`
2323 }
2424
25+// SubagentsConfig contains the persisted supervisor policy. Duration values
26+// use Go strings such as "20m" so config files remain readable.
27+type SubagentsConfig struct {
28+ MaxConcurrent int `json:"max_concurrent,omitempty"`
29+ MaxConcurrentPerParent int `json:"max_concurrent_per_parent,omitempty"`
30+ MaxTotalSpawned int `json:"max_total_spawned,omitempty"`
31+ QueueTimeout string `json:"queue_timeout,omitempty"`
32+ DefaultTimeout string `json:"default_timeout,omitempty"`
33+ MaxTurns int `json:"max_turns,omitempty"`
34+ MaxOutputBytes int `json:"max_output_bytes,omitempty"`
35+ MaxOutputLines int `json:"max_output_lines,omitempty"`
36+ AllowedTools []string `json:"allowed_tools,omitempty"`
37+ AllowedRoots []string `json:"allowed_roots,omitempty"`
38+ HeartbeatInterval string `json:"heartbeat_interval,omitempty"`
39+ IdleTimeout string `json:"idle_timeout,omitempty"`
40+ ReconnectTimeout string `json:"reconnect_timeout,omitempty"`
41+ CancelGracePeriod string `json:"cancel_grace_period,omitempty"`
42+}
43+
2544 // Config is the persisted user configuration.
2645 type Config struct {
2746 Provider string `json:"provider"`
@@ -68,17 +87,20 @@ type Config struct {
6887 // both so no extra provider request is made. Toggle from /settings.
6988 TerminalTitleEnabled *bool `json:"terminal_title_enabled,omitempty"`
7089
71- // AutoSwarmEnabled lets the main agent spawn background sub-agents
72- // for parallel sub-tasks via a built-in swarm_spawn tool. Off by
90+ // Subagents controls manager-owned child lifecycle and resource policy.
91+ Subagents SubagentsConfig `json:"subagents,omitempty"`
92+
93+ // AutoSubagentsEnabled lets the main agent spawn background sub-agents
94+ // for parallel sub-tasks via a built-in subagent_spawn tool. Off by
7395 // default; nil/missing means disabled. Toggle from /settings.
74- AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
96+ AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"`
7597
7698 // LSPEnabled controls the built-in lsp tool and write-time diagnostics
7799 // for the main session. nil/missing means enabled. Toggle from
78100 // /settings.
79101 LSPEnabled *bool `json:"lsp_enabled,omitempty"`
80102
81- // SubagentLSPEnabled controls LSP availability in swarm sub-agent
103+ // SubagentLSPEnabled controls LSP availability in subagent worker
82104 // processes. nil/missing means enabled. Toggle from /settings.
83105 SubagentLSPEnabled *bool `json:"subagent_lsp_enabled,omitempty"`
84106
@@ -213,7 +235,7 @@ func (c Config) CompactUserInput() bool {
213235 return c.CompactInput != nil && *c.CompactInput
214236 }
215237
216-// LSPEnabledFor reports whether LSP is enabled for a main or swarm
238+// LSPEnabledFor reports whether LSP is enabled for a main or subagent
217239 // sub-agent session. Both settings default to true so new installations
218240 // get code intelligence without a migration or generated config file.
219241 func (c Config) LSPEnabledFor(subagent bool) bool {
A packages/agent/modes/auto_subagents_policy_test.go +178 -0
@@ -0,0 +1,178 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/packages/agent/subagents"
10+ "github.com/patriceckhart/zot/packages/core"
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) {
15+ trueValue := true
16+ falseValue := false
17+
18+ tests := []struct {
19+ name string
20+ supervisor *subagents.Supervisor
21+ allowed *bool
22+ wantHint string
23+ }{
24+ {
25+ name: "supervisor unavailable",
26+ allowed: &trueValue,
27+ wantHint: "subagent supervisor not available in this mode",
28+ },
29+ {
30+ name: "launch policy excludes tool",
31+ supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}),
32+ allowed: &falseValue,
33+ wantHint: "launch-time tool policy excludes subagent_spawn",
34+ },
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ if tt.supervisor != nil {
39+ t.Cleanup(tt.supervisor.StopAll)
40+ }
41+ iv := &Interactive{
42+ cfg: InteractiveConfig{
43+ AutoSubagentsEnabled: &trueValue,
44+ AutoSubagentsToolAllowed: tt.allowed,
45+ Supervisor: tt.supervisor,
46+ },
47+ settingsDialog: newSettingsDialog(),
48+ }
49+ iv.openSettingsDialog()
50+
51+ var item settingsItem
52+ for _, candidate := range iv.settingsDialog.items {
53+ if candidate.key == "auto_subagents_enabled" {
54+ item = candidate
55+ break
56+ }
57+ }
58+ if !item.disabled {
59+ t.Fatal("auto-subagents setting is not disabled")
60+ }
61+ if item.value {
62+ t.Fatal("disabled auto-subagents setting is shown as enabled")
63+ }
64+ if !strings.Contains(item.hint, tt.wantHint) {
65+ t.Fatalf("hint = %q; want %q", item.hint, tt.wantHint)
66+ }
67+ })
68+ }
69+}
70+
71+func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
72+ allowed := false
73+ enabled := false
74+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
75+ t.Cleanup(supervisor.StopAll)
76+ iv := &Interactive{
77+ agent: &core.Agent{Tools: core.Registry{}},
78+ cfg: InteractiveConfig{
79+ AutoSubagentsEnabled: &enabled,
80+ AutoSubagentsToolAllowed: &allowed,
81+ Supervisor: supervisor,
82+ },
83+ dirty: make(chan struct{}, 1),
84+ }
85+
86+ iv.applyAutoSubagentsTool(true)
87+ if _, ok := iv.agent.Tools["subagent_spawn"]; ok {
88+ t.Fatal("subagent_spawn registered despite launch-time policy")
89+ }
90+ iv.applySettingToggle("auto_subagents_enabled", true)
91+ iv.mu.Lock()
92+ statusErr := iv.statusErr
93+ value := iv.cfg.AutoSubagentsEnabled != nil && *iv.cfg.AutoSubagentsEnabled
94+ iv.mu.Unlock()
95+ if value {
96+ t.Fatal("auto-subagents toggle enabled despite launch-time policy")
97+ }
98+ if !strings.Contains(statusErr, "launch-time tool policy") {
99+ t.Fatalf("toggle error = %q; want launch-time policy hint", statusErr)
100+ }
101+}
102+
103+type serializedAutoSubagentsClient struct {
104+ started chan struct{}
105+ request chan provider.Request
106+ release chan struct{}
107+ finished chan struct{}
108+}
109+
110+func (c *serializedAutoSubagentsClient) Name() string { return "serialized-auto-subagents-test" }
111+
112+func (c *serializedAutoSubagentsClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
113+ c.request <- req
114+ c.started <- struct{}{}
115+ out := make(chan provider.Event, 1)
116+ go func() {
117+ defer close(out)
118+ select {
119+ case <-c.release:
120+ out <- provider.EventDone{Stop: provider.StopEnd}
121+ case <-ctx.Done():
122+ out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
123+ }
124+ close(c.finished)
125+ }()
126+ return out, nil
127+}
128+
129+func TestAutoSubagentsPromptConfigurationIsSynchronized(t *testing.T) {
130+ client := &serializedAutoSubagentsClient{
131+ started: make(chan struct{}, 1),
132+ request: make(chan provider.Request, 1),
133+ release: make(chan struct{}),
134+ finished: make(chan struct{}),
135+ }
136+ agent := core.NewAgent(client, "test-model", "base system", nil)
137+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
138+ t.Cleanup(supervisor.StopAll)
139+ allowed := true
140+ iv := NewInteractive(InteractiveConfig{
141+ Agent: agent,
142+ Supervisor: supervisor,
143+ AutoSubagentsToolAllowed: &allowed,
144+ AutoSubagentsSystemAddendum: "delegation guidance",
145+ })
146+
147+ iv.startTurn(context.Background(), "hello")
148+ select {
149+ case <-client.started:
150+ case <-time.After(time.Second):
151+ t.Fatal("prompt did not reach provider")
152+ }
153+
154+ request := <-client.request
155+ if request.System != "base system" {
156+ t.Fatalf("provider request system = %q; want base system snapshot", request.System)
157+ }
158+ applied := make(chan struct{})
159+ go func() {
160+ iv.applyAutoSubagentsSystemPrompt(true)
161+ close(applied)
162+ }()
163+ select {
164+ case <-applied:
165+ case <-time.After(time.Second):
166+ t.Fatal("system-prompt update did not apply while provider was running")
167+ }
168+ if system, _ := agent.PromptConfig(); !strings.Contains(system, "delegation guidance") {
169+ t.Fatalf("system prompt missing applied guidance: %q", system)
170+ }
171+
172+ close(client.release)
173+ select {
174+ case <-client.finished:
175+ case <-time.After(time.Second):
176+ t.Fatal("prompt did not finish after provider release")
177+ }
178+}
R packages/agent/modes/auto_swarm_test.go → packages/agent/modes/auto_subagents_test.go +40 -40 renamed, 58% similar
@@ -7,64 +7,64 @@ import (
77 "testing"
88 "time"
99
10- "github.com/patriceckhart/zot/packages/agent/swarm"
10+ "github.com/patriceckhart/zot/packages/agent/subagents"
1111 "github.com/patriceckhart/zot/packages/core"
1212 )
1313
14-func TestAutoSwarmSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) {
14+func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) {
1515 iv := &Interactive{
1616 agent: &core.Agent{System: "base system"},
1717 cfg: InteractiveConfig{
18- AutoSwarmSystemAddendum: "auto-swarm guidance",
19- SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",
18+ AutoSubagentsSystemAddendum: "auto-subagents guidance",
19+ SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",
2020 },
2121 }
2222
23- iv.applyAutoSwarmSystemPrompt(true)
24- if !strings.Contains(iv.agent.System, "[subagents_list]") || !strings.Contains(iv.agent.System, "auto-swarm guidance") {
25- t.Fatalf("enabled system prompt missing swarm blocks: %q", iv.agent.System)
23+ iv.applyAutoSubagentsSystemPrompt(true)
24+ if !strings.Contains(iv.agent.System, "[subagents_list]") || !strings.Contains(iv.agent.System, "auto-subagents guidance") {
25+ t.Fatalf("enabled system prompt missing subagent blocks: %q", iv.agent.System)
2626 }
2727 before := iv.agent.System
28- iv.applyAutoSwarmSystemPrompt(true)
28+ iv.applyAutoSubagentsSystemPrompt(true)
2929 if iv.agent.System != before {
3030 t.Fatalf("enabling twice changed system prompt: %q -> %q", before, iv.agent.System)
3131 }
3232
33- iv.applyAutoSwarmSystemPrompt(false)
34- if strings.Contains(iv.agent.System, "[subagents_list]") || strings.Contains(iv.agent.System, "auto-swarm guidance") {
35- t.Fatalf("disabled system prompt retained swarm blocks: %q", iv.agent.System)
33+ iv.applyAutoSubagentsSystemPrompt(false)
34+ if strings.Contains(iv.agent.System, "[subagents_list]") || strings.Contains(iv.agent.System, "auto-subagents guidance") {
35+ t.Fatalf("disabled system prompt retained subagent blocks: %q", iv.agent.System)
3636 }
3737 }
3838
39-func TestAutoSwarmTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
39+func TestAutoSubagentsTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
4040 manifest := "[subagents_list]\n- reviewer\n[/subagents_list]"
41- guidance := "auto-swarm guidance"
41+ guidance := "auto-subagents guidance"
4242 base := "base system\n\n" + manifest + "\n\n" + guidance
4343 iv := &Interactive{
4444 agent: &core.Agent{System: base},
4545 cfg: InteractiveConfig{
46- AutoSwarmSystemAddendum: guidance,
47- SubagentsSystemAddendum: manifest,
46+ AutoSubagentsSystemAddendum: guidance,
47+ SubagentsSystemAddendum: manifest,
4848 },
4949 }
5050
51- iv.applyAutoSwarmSystemPrompt(true)
51+ iv.applyAutoSubagentsSystemPrompt(true)
5252 if strings.Count(iv.agent.System, manifest) != 2 || strings.Count(iv.agent.System, guidance) != 2 {
5353 t.Fatalf("enabling should append owned duplicate blocks: %q", iv.agent.System)
5454 }
55- iv.applyAutoSwarmSystemPrompt(false)
55+ iv.applyAutoSubagentsSystemPrompt(false)
5656 if got := strings.TrimSpace(iv.agent.System); got != base {
5757 t.Fatalf("disabling changed matching base prompt: %q; want %q", got, base)
5858 }
5959 }
6060
61-func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {
61+func TestAutoSubagentsSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {
6262 response := "first response line\n" + strings.Repeat("result ", 150) + "final marker"
63- mgr := swarm.New(swarm.Config{
63+ mgr := subagents.New(subagents.Config{
6464 Root: t.TempDir(),
6565 RepoRoot: t.TempDir(),
66- NewRunner: func(*swarm.Agent) swarm.Runner {
67- return swarm.RunnerFunc(func(_ context.Context, sink swarm.Sink) error {
66+ NewRunner: func(*subagents.Agent) subagents.Runner {
67+ return subagents.RunnerFunc(func(_ context.Context, sink subagents.Sink) error {
6868 sink.Transcript(response)
6969 return nil
7070 })
@@ -75,20 +75,20 @@ func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T
7575 t.Fatal(err)
7676 }
7777
78- iv := newQueuedAutoSwarmInteractive()
79- iv.TrackSwarmAgent(a, a.Task)
78+ iv := newQueuedAutoSubagentsInteractive()
79+ iv.TrackSubagentWorker(a, a.Task)
8080 update := waitForQueuedPrompt(t, iv)
8181 if !strings.Contains(update, "final response:\n"+response) {
8282 t.Fatalf("update missing complete final response: %q", update)
8383 }
8484 }
8585
86-func TestTrackSwarmAgentReportsStartupFailure(t *testing.T) {
87- mgr := swarm.New(swarm.Config{
86+func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) {
87+ mgr := subagents.New(subagents.Config{
8888 Root: t.TempDir(),
8989 RepoRoot: t.TempDir(),
90- NewRunner: func(*swarm.Agent) swarm.Runner {
91- return swarm.RunnerFunc(func(context.Context, swarm.Sink) error {
90+ NewRunner: func(*subagents.Agent) subagents.Runner {
91+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
9292 return errors.New("listener startup failed")
9393 })
9494 },
@@ -98,8 +98,8 @@ func TestTrackSwarmAgentReportsStartupFailure(t *testing.T) {
9898 t.Fatal(err)
9999 }
100100
101- iv := newQueuedAutoSwarmInteractive()
102- iv.TrackSwarmAgent(a, a.Task)
101+ iv := newQueuedAutoSubagentsInteractive()
102+ iv.TrackSubagentWorker(a, a.Task)
103103
104104 update := waitForQueuedPrompt(t, iv)
105105 if !strings.Contains(update, "status: failed") {
@@ -110,13 +110,13 @@ func TestTrackSwarmAgentReportsStartupFailure(t *testing.T) {
110110 }
111111 }
112112
113-func TestCompleteSwarmWatchReportsTurnOutcomeOnce(t *testing.T) {
113+func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
114114 started := make(chan struct{})
115- mgr := swarm.New(swarm.Config{
115+ mgr := subagents.New(subagents.Config{
116116 Root: t.TempDir(),
117117 RepoRoot: t.TempDir(),
118- NewRunner: func(*swarm.Agent) swarm.Runner {
119- return swarm.RunnerFunc(func(ctx context.Context, _ swarm.Sink) error {
118+ NewRunner: func(*subagents.Agent) subagents.Runner {
119+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
120120 close(started)
121121 <-ctx.Done()
122122 return ctx.Err()
@@ -133,11 +133,11 @@ func TestCompleteSwarmWatchReportsTurnOutcomeOnce(t *testing.T) {
133133 a.Wait()
134134 })
135135
136- iv := newQueuedAutoSwarmInteractive()
137- entry := &swarmWatchEntry{agent: a, task: a.Task}
138- iv.swarmWatch = []*swarmWatchEntry{entry}
139- iv.completeSwarmWatchEntry(entry, "completed", "")
140- iv.completeSwarmWatchEntry(entry, "failed", "late error")
136+ iv := newQueuedAutoSubagentsInteractive()
137+ entry := &subagentWatchEntry{agent: a, task: a.Task}
138+ iv.subagentWatch = []*subagentWatchEntry{entry}
139+ iv.completeSupervisorWatchEntry(entry, "completed", "")
140+ iv.completeSupervisorWatchEntry(entry, "failed", "late error")
141141
142142 update := waitForQueuedPrompt(t, iv)
143143 if !strings.Contains(update, "status: completed") {
@@ -151,7 +151,7 @@ func TestCompleteSwarmWatchReportsTurnOutcomeOnce(t *testing.T) {
151151 }
152152 }
153153
154-func newQueuedAutoSwarmInteractive() *Interactive {
154+func newQueuedAutoSubagentsInteractive() *Interactive {
155155 return &Interactive{
156156 agent: &core.Agent{},
157157 busy: true,
@@ -176,7 +176,7 @@ func waitForQueuedPrompt(t *testing.T, iv *Interactive) string {
176176 iv.mu.Unlock()
177177 select {
178178 case <-deadline.C:
179- t.Fatal("timed out waiting for auto-swarm update")
179+ t.Fatal("timed out waiting for auto-subagents update")
180180 case <-ticker.C:
181181 }
182182 }
M packages/agent/modes/dialog_text_test.go +7 -7
@@ -6,7 +6,7 @@ import (
66
77 "github.com/mattn/go-runewidth"
88
9- "github.com/patriceckhart/zot/packages/agent/swarm"
9+ "github.com/patriceckhart/zot/packages/agent/subagents"
1010 "github.com/patriceckhart/zot/packages/tui"
1111 )
1212
@@ -47,10 +47,10 @@ func TestBtwDialogWrapsAssistantAndKeepsCursorAligned(t *testing.T) {
4747 }
4848 }
4949
50-func TestSwarmTranscriptWrapsAssistantAndDiagnosticRows(t *testing.T) {
50+func TestSupervisorTranscriptWrapsAssistantAndDiagnosticRows(t *testing.T) {
5151 const width = 80
5252 long := strings.Repeat("long response text ", 8)
53- rows := renderSwarmTranscriptBlocks([]string{
53+ rows := renderSupervisorTranscriptBlocks([]string{
5454 long,
5555 "stderr: " + long,
5656 "error: " + long,
@@ -65,15 +65,15 @@ func TestSwarmTranscriptWrapsAssistantAndDiagnosticRows(t *testing.T) {
6565 }
6666 }
6767
68-func TestSwarmDialogViewingWrapsSpawnedAgentResponse(t *testing.T) {
68+func TestSubagentsDialogViewingWrapsSpawnedAgentResponse(t *testing.T) {
6969 const width = 80
7070 response := strings.Repeat("spawned agent response ", 6)
71- rows := []swarm.AgentSnapshot{{
71+ rows := []subagents.AgentSnapshot{{
7272 ID: "agent-1",
73- Status: swarm.StatusDone,
73+ Status: subagents.StatusDone,
7474 Lines: []string{response},
7575 }}
76- d := newSwarmDialog()
76+ d := newSubagentsDialog()
7777 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
7878 _ = d.Render(tui.Theme{}, width)
7979 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
M packages/agent/modes/double_escape.go +1 -1
@@ -79,7 +79,7 @@ func (i *Interactive) sessionTreeEscapeBlocked() bool {
7979 i.llamaDialog != nil && i.llamaDialog.Active() ||
8080 i.rescueDialog != nil && i.rescueDialog.Active() ||
8181 i.sessionDialog != nil && i.sessionDialog.Active() ||
82- i.swarmDialog != nil && i.swarmDialog.Active() ||
82+ i.subagentsDialog != nil && i.subagentsDialog.Active() ||
8383 i.jumpDialog != nil && i.jumpDialog.Active() ||
8484 i.btwDialog != nil && i.btwDialog.Active() ||
8585 i.skillsDialog != nil && i.skillsDialog.Active() ||
M packages/agent/modes/interactive.go +217 -144
@@ -18,7 +18,6 @@ import (
1818 "github.com/patriceckhart/zot/packages/agent/modes/telegram"
1919 "github.com/patriceckhart/zot/packages/agent/skills"
2020 "github.com/patriceckhart/zot/packages/agent/subagents"
21- "github.com/patriceckhart/zot/packages/agent/swarm"
2221 "github.com/patriceckhart/zot/packages/agent/tools"
2322 "github.com/patriceckhart/zot/packages/core"
2423 "github.com/patriceckhart/zot/packages/provider"
@@ -68,10 +67,14 @@ type InteractiveConfig struct {
6867 // title updates. nil means enabled; false disables both.
6968 TerminalTitleEnabled *bool
7069
71- // AutoSwarmEnabled mirrors the persisted config flag at startup so
70+ // AutoSubagentsEnabled mirrors the persisted config flag at startup so
7271 // the /settings dialog can render the current state without
7372 // re-reading config.json on every open.
74- AutoSwarmEnabled *bool
73+ AutoSubagentsEnabled *bool
74+
75+ // AutoSubagentsToolAllowed is nil for normal sessions and false when the
76+ // launch-time --no-tools/--tools policy excludes delegation.
77+ AutoSubagentsToolAllowed *bool
7578
7679 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.
7780 // nil/missing means disabled. Unsupported providers reject attempts
@@ -82,7 +85,7 @@ type InteractiveConfig struct {
8285 // enabled by default.
8386 LSPEnabled *bool
8487
85- // SubagentLSPEnabled controls whether newly spawned swarm children
88+ // SubagentLSPEnabled controls whether newly spawned subagent children
8689 // receive the lsp tool. nil means enabled by default.
8790 SubagentLSPEnabled *bool
8891
@@ -141,14 +144,14 @@ type InteractiveConfig struct {
141144 // ExtensionThemes returns themes bundled with loaded extensions.
142145 ExtensionThemes func() []tui.ThemeOption
143146
144- // AutoSwarmSystemAddendum is the system-prompt block that gets
145- // appended/stripped when the user toggles auto-swarm at runtime.
147+ // AutoSubagentsSystemAddendum is the system-prompt block that gets
148+ // appended/stripped when the user toggles auto-subagents at runtime.
146149 // Plumbed in from the cli so this package doesn't have to import
147150 // agent (cycle).
148- AutoSwarmSystemAddendum string
151+ AutoSubagentsSystemAddendum string
149152
150153 // SubagentsSystemAddendum is the metadata-only [subagents_list]
151- // block that is added or removed together with auto-swarm.
154+ // block that is added or removed together with auto-subagents.
152155 SubagentsSystemAddendum string
153156 SettingsStore SettingsStore
154157
@@ -315,15 +318,15 @@ type InteractiveConfig struct {
315318 // AFTER the built-in catalog so a built-in name always wins.
316319 Extensions *extensions.Manager
317320
318- // Swarm, if non-nil, enables the /swarm slash command and the
319- // dashboard dialog. The cli constructs the Swarm once per
321+ // Supervisor, if non-nil, enables the /subagents slash command and the
322+ // dashboard dialog. The cli constructs the Supervisor once per
320323 // interactive run and tears it down on exit. nil disables the
321324 // feature entirely (used by embedders / tests that don't want
322325 // subprocesses).
323- Swarm *swarm.Swarm
326+ Supervisor *subagents.Supervisor
324327
325328 // ResolveSubagent validates a named markdown profile for direct
326- // /swarm commands. Auto-swarm uses the equivalent callback on its
329+ // /subagents commands. Auto-subagents uses the equivalent callback on its
327330 // tool; keeping this optional preserves lightweight embedders.
328331 ResolveSubagent func(name string) (*subagents.Profile, error)
329332
@@ -410,7 +413,7 @@ type extensionWidget struct {
410413 type SettingsStore interface {
411414 SetQuickModelShortcut(slot int, providerName, model string) error
412415 SetInlineImages(enabled bool) error
413- SetAutoSwarm(enabled bool) error
416+ SetAutoSubagents(enabled bool) error
414417 SetJailByDefault(enabled bool) error
415418 SetRecursiveFileSuggest(enabled bool) error
416419 SetRespectGitignore(enabled bool) error
@@ -455,13 +458,16 @@ type Interactive struct {
455458
456459 mu sync.Mutex
457460 agent *core.Agent
458- // managedAutoSwarmAddenda records exact prompt blocks appended by
459- // auto-swarm. Disable only removes these owned occurrences, leaving
461+ // agentMu serializes live registry replacement with side-dialog
462+ // snapshots, whose copied-agent constructor reads the registry field.
463+ agentMu sync.Mutex
464+ // managedAutoSubagentsAddenda records exact prompt blocks appended by
465+ // auto-subagents. Disable only removes these owned occurrences, leaving
460466 // identical text that came from the user's base prompt untouched.
461- managedAutoSwarmAddenda []string
462- streaming strings.Builder // what's currently painted on screen
463- streamOn bool
464- pendingAlert *extproto.AlertRequest
467+ managedAutoSubagentsAddenda []string
468+ streaming strings.Builder // what's currently painted on screen
469+ streamOn bool
470+ pendingAlert *extproto.AlertRequest
465471
466472 // streamPending is the runes buffered after each EvTextDelta that
467473 // haven't yet been promoted into `streaming` for rendering. It
@@ -572,7 +578,7 @@ type Interactive struct {
572578 llamaDialog *llamaDialog
573579 rescueDialog *rescueDialog
574580 sessionDialog *sessionDialog
575- swarmDialog *swarmDialog
581+ subagentsDialog *subagentsDialog
576582 jumpDialog *jumpDialog
577583 btwDialog *btwDialog
578584 skillsDialog *skillsDialog
@@ -588,14 +594,18 @@ type Interactive struct {
588594 extPanel *extPanelDialog
589595 llamaConfigured bool
590596
591- // swarmWatch tracks auto-swarm sub-agents the main agent spawned
592- // via swarm_spawn. Each entry holds the agent + the task text;
597+ // subagentWatch tracks auto-subagents sub-agents the main agent spawned
598+ // via subagent_spawn. Each entry holds the agent + the task text;
593599 // a per-entry goroutine waits on the agent's terminal state. When
594600 // every tracked entry has finished, the watcher flushes a single
595601 // summary turn into the main chat (queued if the main agent is
596602 // busy, run immediately if idle).
597- swarmWatchMu sync.Mutex
598- swarmWatch []*swarmWatchEntry
603+ subagentWatchMu sync.Mutex
604+ subagentWatch []*subagentWatchEntry
605+
606+ // subagentsWaitWatcherDone is an optional test hook invoked when a
607+ // /subagents wait watcher exits.
608+ subagentsWaitWatcherDone func()
599609
600610 // pendingFork is true when the user ran /session fork: the next
601611 // jump-picker selection should branch off that message instead
@@ -752,7 +762,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
752762 llamaDialog: newLlamaDialog(),
753763 rescueDialog: newRescueDialog(),
754764 sessionDialog: newSessionDialog(),
755- swarmDialog: newSwarmDialog(),
765+ subagentsDialog: newSubagentsDialog(),
756766 jumpDialog: newJumpDialog(),
757767 btwDialog: newBtwDialog(),
758768 skillsDialog: newSkillsDialog(),
@@ -779,8 +789,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
779789 baseURL, _, err := cfg.LlamaCPPConfig()
780790 i.llamaConfigured = err == nil && baseURL != ""
781791 }
782- if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
783- i.managedAutoSwarmAddenda = autoSwarmAddenda(cfg)
792+ if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled && cfg.Supervisor != nil && (cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed) {
793+ i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)
784794 }
785795 if cfg.Agent != nil {
786796 i.agent = cfg.Agent
@@ -808,8 +818,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
808818 i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages)
809819 i.titleGenerationStarted = i.titleRealPromptSeen
810820 }
811- if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
812- i.applyAutoSwarmTool(true)
821+ if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
822+ i.applyAutoSubagentsTool(true)
813823 }
814824 return i
815825 }
@@ -1066,7 +1076,7 @@ func (i *Interactive) Run(ctx context.Context) error {
10661076 // blink inside dialogs that host their own editor (btw),
10671077 // because each frame re-emits hide-cursor + show-cursor.
10681078 //
1069- // The swarm dashboard is also animated: its rows reflect
1079+ // The subagent dashboard is also animated: its rows reflect
10701080 // background subprocesses whose activity / age change
10711081 // without any user input. Without the tick redraw the
10721082 // dashboard freezes on the snapshot taken when the user
@@ -1074,7 +1084,7 @@ func (i *Interactive) Run(ctx context.Context) error {
10741084 // of its inline editors (spawn task or prompt composer)
10751085 // is active so the cursor blink in those editors works
10761086 // the same way it does inside btw.
1077- if i.busy || i.btwDialog.Loading() || i.swarmDialog.NeedsTickRefresh() {
1087+ if i.busy || i.btwDialog.Loading() || i.subagentsDialog.NeedsTickRefresh() {
10781088 requestRedraw()
10791089 }
10801090 }
@@ -1541,8 +1551,8 @@ func (i *Interactive) redraw() {
15411551 }
15421552 i.sessionDialog.MaxRows = avail
15431553 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
1544- case i.swarmDialog.Active():
1545- dialog = i.swarmDialog.Render(i.cfg.Theme, cols)
1554+ case i.subagentsDialog.Active():
1555+ dialog = i.subagentsDialog.Render(i.cfg.Theme, cols)
15461556 case i.jumpDialog.Active():
15471557 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
15481558 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
@@ -1692,6 +1702,7 @@ func (i *Interactive) redraw() {
16921702 Provider: i.cfg.Provider,
16931703 Model: i.cfg.Model,
16941704 Reasoning: i.cfg.Reasoning,
1705+ FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
16951706 Busy: i.busy,
16961707 BusyPrefix: statusBusyPrefix,
16971708 CWD: i.cfg.CWD,
@@ -1765,15 +1776,15 @@ func (i *Interactive) redraw() {
17651776 bottom = append(bottom, "")
17661777 }
17671778 bottom = append(bottom, dialog...)
1768- // The swarm dashboard owns the bottom of the screen while it's
1779+ // The subagent dashboard owns the bottom of the screen while it's
17691780 // active: it has its own inline editors for spawn (`n`) and
17701781 // prompt (`p`), so the main input would be a confusing second
17711782 // caret. The suggest popup, sliding-in queue, status block, and
17721783 // main editor are all hidden underneath it. Keystrokes still
1773- // reach handleKey — it routes them to swarmDialog.HandleKey
1784+ // reach handleKey — it routes them to subagentsDialog.HandleKey
17741785 // before the editor ever sees them — so the only effect of this
17751786 // branch is visual.
1776- if !i.swarmDialog.Active() {
1787+ if !i.subagentsDialog.Active() {
17771788 bottom = append(bottom, suggest...)
17781789 bottom = append(bottom, queue...)
17791790 lineInput := inputStyle == tui.InputStyleLines
@@ -2004,8 +2015,8 @@ func (i *Interactive) redraw() {
20042015 cursorCol = c
20052016 }
20062017 }
2007- if i.swarmDialog.Active() {
2008- if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {
2018+ if i.subagentsDialog.Active() {
2019+ if r, c := i.subagentsDialog.CursorPos(cols); r >= 0 {
20092020 cursorRow = dialogLead + r
20102021 cursorCol = c
20112022 } else {
@@ -2307,7 +2318,7 @@ func (i *Interactive) confirmChildActive() bool {
23072318 i.llamaDialog.Active() ||
23082319 i.rescueDialog.Active() ||
23092320 i.sessionDialog.Active() ||
2310- i.swarmDialog.Active() ||
2321+ i.subagentsDialog.Active() ||
23112322 i.jumpDialog.Active() ||
23122323 i.btwDialog.Active() ||
23132324 i.skillsDialog.Active() ||
@@ -2515,13 +2526,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
25152526 i.invalidate()
25162527 return false
25172528 }
2518- if i.swarmDialog.Active() {
2529+ if i.subagentsDialog.Active() {
25192530 if k.Kind == tui.KeyCtrlC {
2520- i.swarmDialog.Close()
2531+ i.subagentsDialog.Close()
25212532 i.invalidate()
25222533 return false
25232534 }
2524- _, msg, errMsg := i.swarmDialog.HandleKey(k)
2535+ _, msg, errMsg := i.subagentsDialog.HandleKey(k)
25252536 if msg != "" || errMsg != "" {
25262537 i.mu.Lock()
25272538 i.statusOK = msg
@@ -3500,10 +3511,10 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
35003511 i.agent = ag
35013512 i.cfg.CWD = cwd
35023513 i.cfg.SubagentsSystemAddendum = subagentsAddendum
3503- if i.autoSwarmEnabled() {
3504- i.managedAutoSwarmAddenda = autoSwarmAddenda(i.cfg)
3514+ if i.autoSubagentsEnabledLocked() {
3515+ i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg)
35053516 } else {
3506- i.managedAutoSwarmAddenda = nil
3517+ i.managedAutoSubagentsAddenda = nil
35073518 }
35083519 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
35093520 i.view.StartupContextPaths = nil
@@ -3847,15 +3858,14 @@ func (i *Interactive) openSettingsDialog() {
38473858
38483859 terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
38493860 terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled)
3850- autoSwarm := false
3851- if i.cfg.AutoSwarmEnabled != nil {
3852- autoSwarm = *i.cfg.AutoSwarmEnabled
3861+ autoSubagents := false
3862+ if i.cfg.AutoSubagentsEnabled != nil {
3863+ autoSubagents = *i.cfg.AutoSubagentsEnabled
38533864 }
3854- autoSwarmDisabled := i.cfg.Swarm == nil
3855- autoSwarmHint := ""
3856- if autoSwarmDisabled {
3857- autoSwarm = false
3858- autoSwarmHint = "swarm supervisor not available in this mode"
3865+ autoSubagentsDisabled := !i.autoSubagentsAvailable()
3866+ autoSubagentsHint := i.autoSubagentsUnavailableHint()
3867+ if autoSubagentsDisabled {
3868+ autoSubagents = false
38593869 }
38603870
38613871 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
@@ -3977,12 +3987,12 @@ func (i *Interactive) openSettingsDialog() {
39773987 value: terminalTitles,
39783988 },
39793989 {
3980- key: "auto_swarm_enabled",
3981- label: "auto-swarm",
3982- desc: "let the agent spawn background sub-agents in parallel via the swarm_spawn tool",
3983- value: autoSwarm,
3984- disabled: autoSwarmDisabled,
3985- hint: autoSwarmHint,
3990+ key: "auto_subagents_enabled",
3991+ label: "auto-subagents",
3992+ desc: "let the agent spawn background sub-agents in parallel via the subagent_spawn tool",
3993+ value: autoSubagents,
3994+ disabled: autoSubagentsDisabled,
3995+ hint: autoSubagentsHint,
39863996 },
39873997 {
39883998 key: "fast_mode",
@@ -4388,28 +4398,37 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
43884398 i.statusOK = "AI terminal titles " + onOff(value)
43894399 i.statusErr = ""
43904400 i.mu.Unlock()
4391- case "auto_swarm_enabled":
4401+ case "auto_subagents_enabled":
4402+ if value && !i.autoSubagentsAvailable() {
4403+ i.mu.Lock()
4404+ i.statusOK = ""
4405+ i.statusErr = "auto-subagents unavailable: " + i.autoSubagentsUnavailableHint()
4406+ i.mu.Unlock()
4407+ return
4408+ }
43924409 val := value
4393- i.cfg.AutoSwarmEnabled = &val
4410+ i.mu.Lock()
4411+ i.cfg.AutoSubagentsEnabled = &val
4412+ i.mu.Unlock()
43944413 if i.cfg.SettingsStore != nil {
4395- if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil {
4414+ if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
43964415 i.mu.Lock()
43974416 i.statusErr = "settings: " + err.Error()
43984417 i.mu.Unlock()
43994418 return
44004419 }
44014420 }
4402- // Add/remove the swarm_spawn tool on the live agent so the
4421+ // Add/remove the subagent_spawn tool on the live agent so the
44034422 // model's tools[] list reflects the toggle on the next turn.
44044423 // Without this the tool stays advertised after a disable and
44054424 // the model keeps trying to call it.
4406- i.applyAutoSwarmTool(value)
4425+ i.applyAutoSubagentsTool(value)
44074426 // Also swap the system-prompt addendum in/out so the model
44084427 // knows to use the tool proactively (or stops referencing it
44094428 // after a disable).
4410- i.applyAutoSwarmSystemPrompt(value)
4429+ i.applyAutoSubagentsSystemPrompt(value)
44114430 i.mu.Lock()
4412- i.statusOK = "auto-swarm " + onOff(value)
4431+ i.statusOK = "auto-subagents " + onOff(value)
44134432 i.statusErr = ""
44144433 i.mu.Unlock()
44154434 case "fast_mode":
@@ -4435,8 +4454,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
44354454 if i.agent != nil {
44364455 i.agent.SetFastMode(value)
44374456 }
4438- if i.cfg.Swarm != nil {
4439- i.cfg.Swarm.SetFastMode(value)
4457+ if i.cfg.Supervisor != nil {
4458+ i.cfg.Supervisor.SetFastMode(value)
44404459 }
44414460 i.mu.Lock()
44424461 i.statusOK = "fast mode " + onOff(value)
@@ -4799,7 +4818,7 @@ func buildStudyPrompt(arg, cwd string) string {
47994818 // keeps Tab as a literal no-op.
48004819 //
48014820 // Free function (not a method) so the same logic runs against the
4802-// editor instances owned by btwDialog and swarmDialog without each
4821+// editor instances owned by btwDialog and subagentsDialog without each
48034822 // dialog needing its own copy.
48044823 func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
48054824 if ed == nil {
@@ -5210,8 +5229,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
52105229 break
52115230 }
52125231 i.openSessionOpsDialog()
5213- case "/swarm":
5214- i.runSwarm(ctx, parts[1:])
5232+ case "/subagents":
5233+ i.runSubagents(ctx, parts[1:])
52155234 default:
52165235 // Last-resort fallback: try the extension manager. Built-in
52175236 // cases above always win; this branch only fires for slash
@@ -5411,7 +5430,7 @@ func (i *Interactive) doLogout(target string) {
54115430 // it so prompts can't go out with the stale client, and hint at
54125431 // /login. Close any LSP processes owned by its tools first.
54135432 if i.agent != nil {
5414- _ = tools.CloseLSPManagers(i.agent.Tools)
5433+ _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
54155434 }
54165435 i.agent = nil
54175436 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
@@ -5621,14 +5640,29 @@ func (i *Interactive) cancelAndWaitForIdle() {
56215640 // submitted as the first question, so '/btw does X work?' fires the
56225641 // model call immediately instead of just opening an empty dialog.
56235642 func (i *Interactive) openBtwDialog(args []string) {
5624- if i.agent == nil {
5643+ i.agentMu.Lock()
5644+ defer i.agentMu.Unlock()
5645+ i.mu.Lock()
5646+ ag := i.agent
5647+ system := ""
5648+ if ag != nil {
5649+ system, _ = ag.PromptConfig()
5650+ }
5651+ theme := i.cfg.Theme
5652+ model := i.cfg.Model
5653+ cwd := i.cfg.CWD
5654+ compact := i.compactModeEnabled()
5655+ flatTools := i.cfg.FlatTools
5656+ lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
5657+ i.mu.Unlock()
5658+ if ag == nil {
56255659 i.mu.Lock()
56265660 i.statusErr = "not logged in. type /login first."
56275661 i.mu.Unlock()
56285662 return
56295663 }
56305664 seed := strings.TrimSpace(strings.Join(args, " "))
5631- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.cfg.FlatTools, tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate)
5665+ i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
56325666 i.invalidate()
56335667 }
56345668
@@ -6005,7 +6039,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
60056039 ag.SeedCost(carryCost)
60066040
60076041 if i.agent != nil && i.agent != ag {
6008- _ = tools.CloseLSPManagers(i.agent.Tools)
6042+ _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
60096043 }
60106044 i.mu.Lock()
60116045 i.agent = ag
@@ -6025,7 +6059,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
60256059 // dynamically-registered tools need to be reattached. The apply
60266060 // helpers are no-ops when their feature is inactive, so the
60276061 // cross-provider path still works on a vanilla setup.
6028- i.applyAutoSwarmTool(i.autoSwarmEnabled())
6062+ i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
60296063 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
60306064 if i.cfg.PersistModel != nil {
60316065 i.cfg.PersistModel(p, md)
@@ -6057,9 +6091,9 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
60576091 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
60586092 i.mu.Unlock()
60596093 if oldAgent != nil {
6060- _ = tools.CloseLSPManagers(oldAgent.Tools)
6094+ _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
60616095 }
6062- i.applyAutoSwarmTool(i.autoSwarmEnabled())
6096+ i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
60636097 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
60646098 // Authentication can change the provider used by the live
60656099 // agent. Persist it on the active session just like /model.
@@ -7205,95 +7239,95 @@ func (a telegramSenderAdapter) Active() bool {
72057239 return a.bridge != nil && a.bridge.Active()
72067240 }
72077241
7208-// swarmWatchEntry is one tracked auto-swarm sub-agent. It records the
7242+// subagentWatchEntry is one tracked auto-subagents sub-agent. It records the
72097243 // delegated turn's outcome separately from the long-lived daemon status.
7210-type swarmWatchEntry struct {
7211- agent *swarm.Agent
7244+type subagentWatchEntry struct {
7245+ agent *subagents.Agent
72127246 task string
72137247 done bool
72147248 outcome string
72157249 err string
72167250 }
72177251
7218-// TrackSwarmAgent is the exported entry point used by the cli to
7219-// hand a freshly-spawned auto-swarm agent off to the watcher.
7220-func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {
7221- i.trackSwarmAgent(a, task)
7252+// TrackSubagentWorker is the exported entry point used by the cli to
7253+// hand a freshly-spawned auto-subagents agent off to the watcher.
7254+func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {
7255+ i.trackSubagentWorker(a, task)
72227256 }
72237257
7224-// trackSwarmAgent records a freshly-spawned auto-swarm agent and
7258+// trackSubagentWorker records a freshly-spawned auto-subagents agent and
72257259 // subscribes to both ways its delegated task can finish. Successful
72267260 // long-lived agents report a prompt-level turn_end and keep listening;
72277261 // startup failures and unexpected daemon exits only unblock Agent.Wait.
72287262 // Whichever signal arrives first completes the entry exactly once.
72297263 //
7230-// Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-
7231-// swarm is enabled, so this is a no-op when the feature is off.
7232-func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) {
7264+// Wired in from cli.go via SubagentSpawnTool.OnSpawned only when auto-
7265+// subagent is enabled, so this is a no-op when the feature is off.
7266+func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) {
72337267 if i == nil || a == nil {
72347268 return
72357269 }
7236- entry := &swarmWatchEntry{agent: a, task: task}
7237- i.swarmWatchMu.Lock()
7238- i.swarmWatch = append(i.swarmWatch, entry)
7239- i.swarmWatchMu.Unlock()
7270+ entry := &subagentWatchEntry{agent: a, task: task}
7271+ i.subagentWatchMu.Lock()
7272+ i.subagentWatch = append(i.subagentWatch, entry)
7273+ i.subagentWatchMu.Unlock()
72407274
72417275 a.SetOnTurnEnd(func(step int, errMsg string) {
72427276 outcome := "completed"
72437277 if errMsg != "" {
72447278 outcome = "failed"
72457279 }
7246- i.completeSwarmWatchEntry(entry, outcome, errMsg)
7280+ i.completeSupervisorWatchEntry(entry, outcome, errMsg)
72477281 })
72487282 go func() {
72497283 a.Wait()
72507284 snap := a.Snapshot()
72517285 outcome := string(snap.Status)
7252- if snap.Status == swarm.StatusDone {
7286+ if snap.Status == subagents.StatusDone {
72537287 outcome = "completed"
72547288 }
7255- i.completeSwarmWatchEntry(entry, outcome, snap.Err)
7289+ i.completeSupervisorWatchEntry(entry, outcome, snap.Err)
72567290 }()
72577291 }
72587292
7259-func (i *Interactive) completeSwarmWatchEntry(entry *swarmWatchEntry, outcome, errMsg string) {
7260- i.swarmWatchMu.Lock()
7293+func (i *Interactive) completeSupervisorWatchEntry(entry *subagentWatchEntry, outcome, errMsg string) {
7294+ i.subagentWatchMu.Lock()
72617295 if entry.done {
7262- i.swarmWatchMu.Unlock()
7296+ i.subagentWatchMu.Unlock()
72637297 return
72647298 }
72657299 entry.done = true
72667300 entry.outcome = outcome
72677301 entry.err = errMsg
72687302 allDone := true
7269- for _, e := range i.swarmWatch {
7303+ for _, e := range i.subagentWatch {
72707304 if !e.done {
72717305 allDone = false
72727306 break
72737307 }
72747308 }
7275- var batch []*swarmWatchEntry
7309+ var batch []*subagentWatchEntry
72767310 if allDone {
7277- batch = i.swarmWatch
7278- i.swarmWatch = nil
7311+ batch = i.subagentWatch
7312+ i.subagentWatch = nil
72797313 }
7280- i.swarmWatchMu.Unlock()
7314+ i.subagentWatchMu.Unlock()
72817315 if len(batch) != 0 {
7282- i.flushSwarmSummary(batch)
7316+ i.flushSupervisorSummary(batch)
72837317 }
72847318 }
72857319
7286-// flushSwarmSummary composes a synthetic user turn describing every
7320+// flushSupervisorSummary composes a synthetic user turn describing every
72877321 // sub-agent's outcome and injects it via SubmitOrQueue so the main
72887322 // agent picks it up at the next safe boundary. The summary is
7289-// phrased as a system update ("Auto-swarm finished: ...") so the
7323+// phrased as a system update ("Auto-subagents finished: ...") so the
72907324 // model treats it as observed state, not as a fresh user request.
7291-func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) {
7325+func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
72927326 if len(batch) == 0 {
72937327 return
72947328 }
72957329 var sb strings.Builder
7296- fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch))
7330+ fmt.Fprintf(&sb, "[auto-subagents update] %d sub-agent(s) finished:\n\n", len(batch))
72977331 for idx, e := range batch {
72987332 snap := e.agent.Snapshot()
72997333 status := e.outcome
@@ -7332,20 +7366,20 @@ func truncateForSummary(s string, n int) string {
73327366 return s[:n-3] + "..."
73337367 }
73347368
7335-// autoSwarmAddenda returns the prompt blocks owned by the auto-swarm
7369+// autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents
73367370 // toggle, in the same order Resolve appends them to a new agent.
7337-func autoSwarmAddenda(cfg InteractiveConfig) []string {
7371+func autoSubagentsAddenda(cfg InteractiveConfig) []string {
73387372 addenda := make([]string, 0, 2)
73397373 if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
73407374 addenda = append(addenda, addendum)
73417375 }
7342- if addendum := strings.TrimSpace(cfg.AutoSwarmSystemAddendum); addendum != "" {
7376+ if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
73437377 addenda = append(addenda, addendum)
73447378 }
73457379 return addenda
73467380 }
73477381
7348-func containsAutoSwarmAddendum(addenda []string, want string) bool {
7382+func containsAutoSubagentsAddendum(addenda []string, want string) bool {
73497383 for _, addendum := range addenda {
73507384 if addendum == want {
73517385 return true
@@ -7354,10 +7388,10 @@ func containsAutoSwarmAddendum(addenda []string, want string) bool {
73547388 return false
73557389 }
73567390
7357-// removeLastAutoSwarmAddendum removes one occurrence of a block known to
7358-// have been appended by auto-swarm. Resolve appends owned blocks at the
7391+// removeLastAutoSubagentsAddendum removes one occurrence of a block known to
7392+// have been appended by auto-subagents. Resolve appends owned blocks at the
73597393 // end, so removing the last occurrence preserves an identical base block.
7360-func removeLastAutoSwarmAddendum(system, addendum string) (string, bool) {
7394+func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
73617395 idx := strings.LastIndex(system, addendum)
73627396 if idx < 0 {
73637397 return system, false
@@ -7365,75 +7399,110 @@ func removeLastAutoSwarmAddendum(system, addendum string) (string, bool) {
73657399 return system[:idx] + system[idx+len(addendum):], true
73667400 }
73677401
7368-// applyAutoSwarmSystemPrompt appends (active=true) or strips
7369-// (active=false) the auto-swarm prompt blocks on the running agent.
7402+// applyAutoSubagentsSystemPrompt appends (active=true) or strips
7403+// (active=false) the auto-subagents prompt blocks on the running agent.
73707404 // The profile manifest and delegation guidance are managed together so
7371-// toggling auto-swarm never leaves the model with names but no tool.
7372-func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
7405+// toggling auto-subagents never leaves the model with names but no tool.
7406+func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
7407+ // i.mu is sufficient here: this path updates the existing agent's system
7408+ // prompt and managed addenda in place; it does not replace the agent or
7409+ // its tool registry, so agentMu is not required.
7410+ i.mu.Lock()
7411+ defer i.mu.Unlock()
73737412 if i.agent == nil {
73747413 return
73757414 }
7376- addenda := autoSwarmAddenda(i.cfg)
7415+ addenda := autoSubagentsAddenda(i.cfg)
73777416 if active {
7378- sys := i.agent.System
7417+ sys, _ := i.agent.PromptConfig()
73797418 for _, addendum := range addenda {
7380- if containsAutoSwarmAddendum(i.managedAutoSwarmAddenda, addendum) {
7419+ if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) {
73817420 continue
73827421 }
73837422 if sys != "" && !strings.HasSuffix(sys, "\n\n") {
73847423 sys += "\n\n"
73857424 }
73867425 sys += addendum
7387- i.managedAutoSwarmAddenda = append(i.managedAutoSwarmAddenda, addendum)
7426+ i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
73887427 }
7389- i.agent.System = sys
7428+ i.agent.SetSystemPrompt(sys)
73907429 return
73917430 }
73927431
7393- sys := i.agent.System
7432+ sys, _ := i.agent.PromptConfig()
73947433 changed := false
7395- for idx := len(i.managedAutoSwarmAddenda) - 1; idx >= 0; idx-- {
7434+ for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
73967435 var removed bool
7397- sys, removed = removeLastAutoSwarmAddendum(sys, i.managedAutoSwarmAddenda[idx])
7436+ sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
73987437 changed = changed || removed
73997438 }
7400- i.managedAutoSwarmAddenda = nil
7439+ i.managedAutoSubagentsAddenda = nil
74017440 if changed {
7402- i.agent.System = strings.TrimRight(sys, "\n") + "\n"
7441+ i.agent.SetSystemPrompt(strings.TrimRight(sys, "\n") + "\n")
74037442 }
74047443 }
74057444
7406-// applyAutoSwarmTool registers (active=true) or removes (active=false)
7407-// the swarm_spawn tool on the running agent so the model only sees it
7408-// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
7445+// applyAutoSubagentsTool registers (active=true) or removes (active=false)
7446+// the subagent_spawn tool on the running agent so the model only sees it
7447+// when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'
74097448 // snapshot+mutate pattern so extension tools and /reload-ext additions
74107449 // survive a toggle.
7411-func (i *Interactive) autoSwarmEnabled() bool {
7412- return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
7450+func (i *Interactive) autoSubagentsAvailable() bool {
7451+ return i.cfg.Supervisor != nil && (i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed)
7452+}
7453+
7454+func (i *Interactive) autoSubagentsUnavailableHint() string {
7455+ var hints []string
7456+ if i.cfg.Supervisor == nil {
7457+ hints = append(hints, "subagent supervisor not available in this mode")
7458+ }
7459+ if !i.autoSubagentsToolAllowed() {
7460+ hints = append(hints, "launch-time tool policy excludes subagent_spawn")
7461+ }
7462+ return strings.Join(hints, "; ")
74137463 }
74147464
7415-func (i *Interactive) applyAutoSwarmTool(active bool) {
7465+func (i *Interactive) autoSubagentsToolAllowed() bool {
7466+ return i.cfg.AutoSubagentsToolAllowed == nil || *i.cfg.AutoSubagentsToolAllowed
7467+}
7468+
7469+func (i *Interactive) autoSubagentsEnabled() bool {
7470+ i.mu.Lock()
7471+ defer i.mu.Unlock()
7472+ return i.autoSubagentsEnabledLocked()
7473+}
7474+
7475+func (i *Interactive) autoSubagentsEnabledLocked() bool {
7476+ return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
7477+}
7478+
7479+func (i *Interactive) applyAutoSubagentsTool(active bool) {
7480+ i.agentMu.Lock()
7481+ defer i.agentMu.Unlock()
7482+ i.mu.Lock()
7483+ defer i.mu.Unlock()
74167484 if i.agent == nil {
74177485 return
74187486 }
7419- current := i.agent.Tools
7487+ current := i.agent.ToolsSnapshot()
74207488 next := core.Registry{}
74217489 for name, t := range current {
7422- if name == "swarm_spawn" {
7490+ if name == "subagent_spawn" {
74237491 continue
74247492 }
74257493 next[name] = t
74267494 }
7427- if active && i.cfg.Swarm != nil {
7428- next["swarm_spawn"] = &tools.SwarmSpawnTool{
7429- Swarm: i.cfg.Swarm,
7430- Enabled: func() bool { return true },
7495+ if active && i.autoSubagentsAvailable() {
7496+ canonical := &tools.SubagentSpawnTool{
7497+ Supervisor: i.cfg.Supervisor,
7498+ Enabled: func() bool { return i.autoSubagentsEnabled() },
74317499 DefaultModel: func() string { return i.cfg.Model },
74327500 DefaultProvider: func() string { return i.cfg.Provider },
74337501 DefaultReasoning: func() string { return i.cfg.Reasoning },
74347502 ResolveSubagent: i.cfg.ResolveSubagent,
7435- OnSpawned: i.trackSwarmAgent,
7503+ OnSpawned: i.trackSubagentWorker,
74367504 }
7505+ next[canonical.Name()] = canonical
74377506 }
74387507 i.agent.SetTools(next)
74397508 }
@@ -7446,10 +7515,14 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
74467515 // later /telegram disconnect (we only add or strip the two telegram
74477516 // entries, never the rest).
74487517 func (i *Interactive) applyTelegramTools(active bool) {
7518+ i.agentMu.Lock()
7519+ defer i.agentMu.Unlock()
7520+ i.mu.Lock()
7521+ defer i.mu.Unlock()
74497522 if i.agent == nil {
74507523 return
74517524 }
7452- current := i.agent.Tools
7525+ current := i.agent.ToolsSnapshot()
74537526 next := core.Registry{}
74547527 for name, t := range current {
74557528 if name == "telegram_send_image" || name == "telegram_send_file" {
M packages/agent/modes/slash_suggest.go +1 -1
@@ -62,7 +62,7 @@ var slashCatalog = []slashCommand{
6262 {Name: "/jail", Desc: "confine tools to the current directory"},
6363 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},
6464 {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"},
65- {Name: "/swarm", Desc: "supervise background agents that share this working directory"},
65+ {Name: "/subagents", Desc: "supervise background subagents with lifecycle, results, and optional worktrees"},
6666 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
6767 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
6868 {Name: "/settings", Desc: "open settings"},
M packages/agent/modes/slash_suggest_test.go +3 -3
@@ -37,10 +37,10 @@ func TestSlashSuggesterShowsLlamaOnlyWhenConfigured(t *testing.T) {
3737 }
3838 }
3939
40-func TestSlashSuggesterHasSwarm(t *testing.T) {
40+func TestSlashSuggesterHasSupervisor(t *testing.T) {
4141 s := newSlashSuggester()
42- if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") {
43- t.Fatalf("/swarm missing from suggestions, got %v", got)
42+ if got := commandNames(s.matches("/su")); !contains(got, "/subagents") {
43+ t.Fatalf("/subagents missing from suggestions, got %v", got)
4444 }
4545 }
4646
R packages/agent/modes/swarm_dialog.go → packages/agent/modes/subagents_dialog.go +97 -94 renamed, 88% similar
@@ -6,13 +6,13 @@ import (
66 "strings"
77 "time"
88
9- "github.com/patriceckhart/zot/packages/agent/swarm"
9+ "github.com/patriceckhart/zot/packages/agent/subagents"
1010 "github.com/patriceckhart/zot/packages/tui"
1111 )
1212
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
13+// subagentsDialog is the dashboard shown by /subagents (no argument) and
14+// /subagents list. It's a read-only window onto a *subagents.Supervisor; the
15+// caller passes a snapshotter so the dialog never holds the subagent
1616 // pointer or its lock.
1717 //
1818 // Keys (list view):
@@ -31,16 +31,16 @@ import (
3131 // p send a follow-up prompt to this agent (inline editor)
3232 // ↑/↓/PgUp/PgDn/Home/End scroll
3333 // esc / q return to the list
34-type swarmDialog struct {
34+type subagentsDialog struct {
3535 active bool
3636 compactMode bool
3737 lineInput bool
38- snapshot func() []swarm.AgentSnapshot
38+ snapshot func() []subagents.AgentSnapshot
3939 stop func(id string) error
4040 remove func(id string) error
4141 // spawn accepts an optional model + provider override (empty
4242 // strings mean "let the child resolve its own default"). The cli
43- // adapter forwards these to swarm.Swarm.SpawnReq.
43+ // adapter forwards these to subagents.Supervisor.SpawnReq.
4444 spawn func(task, model, provider string) error
4545 // send delivers a follow-up user turn to a running agent's inbox.
4646 // Wired by Open(); when nil the inline 'p' shortcut is disabled.
@@ -50,7 +50,7 @@ type swarmDialog struct {
5050 // disabled.
5151 resume func(id string) error
5252
53- rows []swarm.AgentSnapshot
53+ rows []subagents.AgentSnapshot
5454 cursor int
5555 viewing bool // when true, show full transcript of the selected row
5656
@@ -63,7 +63,7 @@ type swarmDialog struct {
6363 transcriptSpin *spinner
6464
6565 // spawning toggles the inline "enter task" editor. While true the
66- // dialog captures keys into a real tui.Editor so swarm-new has the
66+ // dialog captures keys into a real tui.Editor so subagent-new has the
6767 // same editing, paste, drag-drop file chips, and visual style as
6868 // the default input and /btw input.
6969 spawning bool
@@ -74,7 +74,7 @@ type swarmDialog struct {
7474 // pendingModel / pendingProvider are the model + provider every
7575 // freshly-spawned agent will be pinned to. They're seeded from
7676 // the host's currently-active model (via SetCurrentModel) at the
77- // moment the user opens the dashboard, so swarm agents inherit
77+ // moment the user opens the dashboard, so subagent agents inherit
7878 // whatever the user last picked via /model. Empty means "don't
7979 // pin a model; let the child resolve its own default".
8080 pendingModel string
@@ -98,7 +98,7 @@ type swarmDialog struct {
9898
9999 // prompting toggles the inline "send prompt to selected agent"
100100 // editor. Distinct from `spawning` so we don't accidentally
101- // route a follow-up into Swarm.Spawn. The editor and its
101+ // route a follow-up into Supervisor.Spawn. The editor and its
102102 // file-suggest live in their own fields so opening the prompt
103103 // editor while a spawn editor is half-typed doesn't clobber it
104104 // (the dialog enforces one-at-a-time, but the separate state
@@ -113,30 +113,30 @@ type swarmDialog struct {
113113 promptTargetID string
114114 }
115115
116-func newSwarmDialog() *swarmDialog { return &swarmDialog{} }
116+func newSubagentsDialog() *subagentsDialog { return &subagentsDialog{} }
117117
118-// SetCurrentModel pins the model + provider every fresh spawn will
119-// inherit. The host wires this to the same Model / Provider the rest
120-// of zot is currently using, so agents started from the dashboard
121-// run on whatever the user last picked via /model. Pass empty
122-// strings to clear the override (the child then resolves its own
123-// default the same way a bare `zot` invocation does).
124-func (d *swarmDialog) SetCompactMode(enabled bool) {
118+func (d *subagentsDialog) SetCompactMode(enabled bool) {
125119 d.compactMode = enabled
126120 }
127121
128-func (d *swarmDialog) SetLineInput(enabled bool) {
122+func (d *subagentsDialog) SetLineInput(enabled bool) {
129123 d.lineInput = enabled
130124 }
131125
132-func (d *swarmDialog) editorPrompt(th tui.Theme) string {
126+func (d *subagentsDialog) editorPrompt(th tui.Theme) string {
133127 if d.lineInput {
134128 return ""
135129 }
136130 return th.AccentBar(th.Accent)
137131 }
138132
139-func (d *swarmDialog) SetCurrentModel(model, providerID string) {
133+// SetCurrentModel pins the model + provider every fresh spawn will
134+// inherit. The host wires this to the same Model / Provider the rest
135+// of zot is currently using, so agents started from the dashboard
136+// run on whatever the user last picked via /model. Pass empty
137+// strings to clear the override (the child then resolves its own
138+// default the same way a bare `zot` invocation does).
139+func (d *subagentsDialog) SetCurrentModel(model, providerID string) {
140140 d.pendingModel = model
141141 d.pendingProvider = providerID
142142 }
@@ -146,16 +146,16 @@ func (d *swarmDialog) SetCurrentModel(model, providerID string) {
146146 // the user invokes /model inside the spawn editor so the picker can
147147 // filter out unauthenticated providers, matching the global /model
148148 // behaviour.
149-func (d *swarmDialog) SetLoggedInProviders(provs []string) {
149+func (d *subagentsDialog) SetLoggedInProviders(provs []string) {
150150 d.modelPickerLoggedIn = provs
151151 }
152152
153-// Open binds the dialog to a swarm via the provided callbacks. The
153+// Open binds the dialog to a subagent via the provided callbacks. The
154154 // dialog refreshes the snapshot every Render so the dashboard reads
155155 // live state without needing a goroutine. spawn / send are optional;
156156 // when nil the corresponding inline shortcut ('n' / 'p') is disabled.
157-func (d *swarmDialog) Open(
158- snapshot func() []swarm.AgentSnapshot,
157+func (d *subagentsDialog) Open(
158+ snapshot func() []subagents.AgentSnapshot,
159159 stop func(id string) error,
160160 remove func(id string) error,
161161 spawn func(task, model, provider string) error,
@@ -194,7 +194,7 @@ func (d *swarmDialog) Open(
194194 // CursorPos returns the row/col for the terminal cursor while an
195195 // inline editor (spawn or prompt) is active so the caret blinks in
196196 // the right spot. Returns -1, -1 when no input is being captured.
197-func (d *swarmDialog) CursorPos(width int) (row, col int) {
197+func (d *subagentsDialog) CursorPos(width int) (row, col int) {
198198 if !d.Active() {
199199 return -1, -1
200200 }
@@ -264,7 +264,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) {
264264 // belongs on when the always-on editor sits at the bottom of the
265265 // transcript view. Mirrors renderTranscript's row layout so the
266266 // terminal cursor stays glued to the editor as the body grows.
267-func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffset int) int {
267+func (d *subagentsDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffset int) int {
268268 a := d.selected()
269269 if a == nil {
270270 return -1
@@ -299,7 +299,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse
299299 // Full body, no scroll window — the host's outer scrollback
300300 // owns overflow now. Cursor row math just counts every
301301 // rendered line.
302- body := renderSwarmTranscriptBlocks(raw, tui.Theme{}, width, d.compactMode)
302+ body := renderSupervisorTranscriptBlocks(raw, tui.Theme{}, width, d.compactMode)
303303 row += len(body)
304304 }
305305
@@ -311,8 +311,8 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse
311311 return row + editorRowOffset
312312 }
313313
314-func (d *swarmDialog) Active() bool { return d != nil && d.active }
315-func (d *swarmDialog) Close() { d.active = false }
314+func (d *subagentsDialog) Active() bool { return d != nil && d.active }
315+func (d *subagentsDialog) Close() { d.active = false }
316316
317317 // NeedsTickRefresh reports whether the host's animation tick should
318318 // repaint the screen while this dialog is open. True when the
@@ -327,7 +327,7 @@ func (d *swarmDialog) Close() { d.active = false }
327327 // tick refresh. The cursor blink still works because the tick rate
328328 // is well below the terminal's blink rate, and we need the redraws
329329 // for streaming output. /btw makes the same trade-off.
330-func (d *swarmDialog) NeedsTickRefresh() bool {
330+func (d *subagentsDialog) NeedsTickRefresh() bool {
331331 if d == nil || !d.active {
332332 return false
333333 }
@@ -345,12 +345,12 @@ func (d *swarmDialog) NeedsTickRefresh() bool {
345345 }
346346
347347 // OpenViewing opens the dashboard and jumps straight to the
348-// transcript view for the agent with id agentID. Used by /swarm logs
348+// transcript view for the agent with id agentID. Used by /subagents logs
349349 // <id>. If no such agent is found the dashboard opens normally so
350350 // the user can see why (and pick from the list).
351-func (d *swarmDialog) OpenViewing(
351+func (d *subagentsDialog) OpenViewing(
352352 agentID string,
353- snapshot func() []swarm.AgentSnapshot,
353+ snapshot func() []subagents.AgentSnapshot,
354354 stop func(id string) error,
355355 remove func(id string) error,
356356 spawn func(task, model, provider string) error,
@@ -365,9 +365,9 @@ func (d *swarmDialog) OpenViewing(
365365 d.cursor = i
366366 d.viewing = true
367367 // Auto-open the inline editor for running agents so
368- // /swarm logs <id> drops the user straight into a
368+ // /subagents logs <id> drops the user straight into a
369369 // /btw-style send-ready prompt.
370- if r.Status == swarm.StatusRunning && d.send != nil {
370+ if r.Status == subagents.StatusRunning && d.send != nil {
371371 d.openPromptEditor(tui.Theme{}, r.ID)
372372 }
373373 return true
@@ -386,8 +386,8 @@ func (d *swarmDialog) OpenViewing(
386386 // so the user has context: pressing ↑/↓ they can still walk through
387387 // running agents, and the R key is a no-op there (Resume rejects
388388 // non-terminal agents with a clear error).
389-func (d *swarmDialog) OpenForResume(
390- snapshot func() []swarm.AgentSnapshot,
389+func (d *subagentsDialog) OpenForResume(
390+ snapshot func() []subagents.AgentSnapshot,
391391 stop func(id string) error,
392392 remove func(id string) error,
393393 spawn func(task, model, provider string) error,
@@ -410,14 +410,14 @@ func (d *swarmDialog) OpenForResume(
410410 }
411411
412412 // isResumable reports whether a Status is eligible for Resume. Mirror
413-// of the precondition inside Swarm.Resume; centralised so the dialog
413+// of the precondition inside Supervisor.Resume; centralised so the dialog
414414 // and the slash handler agree on what "resumable" means.
415-func isResumable(s swarm.Status) bool {
415+func isResumable(s subagents.Status) bool {
416416 switch s {
417- case swarm.StatusDetached,
418- swarm.StatusDone,
419- swarm.StatusFailed,
420- swarm.StatusKilled:
417+ case subagents.StatusDetached,
418+ subagents.StatusDone,
419+ subagents.StatusFailed,
420+ subagents.StatusKilled:
421421 return true
422422 }
423423 return false
@@ -426,8 +426,8 @@ func isResumable(s swarm.Status) bool {
426426 // promptDisabledHint returns the status-bar message shown when the
427427 // user presses 'p' on an agent that can't receive input. We branch on
428428 // status so the hint points at the right next step: resume for the
429-// states Swarm.Resume accepts; nothing useful otherwise.
430-func promptDisabledHint(s swarm.Status) string {
429+// states Supervisor.Resume accepts; nothing useful otherwise.
430+func promptDisabledHint(s subagents.Status) string {
431431 if isResumable(s) {
432432 return "prompt: agent is " + string(s) + "; press R to resume first"
433433 }
@@ -438,24 +438,24 @@ func promptDisabledHint(s swarm.Status) string {
438438 // Kill only makes sense on running / pending agents; on detached and
439439 // terminal ones it's a no-op and the user usually wants 'r' (remove)
440440 // to clear out the agent's state instead.
441-func killDisabledHint(s swarm.Status) string {
441+func killDisabledHint(s subagents.Status) string {
442442 return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)"
443443 }
444444
445445 // friendlySendErr turns a raw send error into a status-bar message
446446 // the user can act on. The most common failure mode — dialling an
447447 // agent whose previous subprocess exited — surfaces as
448-// swarm.ErrNotReady; we rewrite it to mention resume instead of
448+// subagents.ErrNotReady; we rewrite it to mention resume instead of
449449 // leaking the unix-socket path. Other errors fall through verbatim
450450 // because they're rare and almost always indicate a real bug.
451451 func friendlySendErr(id string, err error) string {
452- if errors.Is(err, swarm.ErrNotReady) {
452+ if errors.Is(err, subagents.ErrNotReady) {
453453 return "send: agent " + id + " isn't accepting input (press R to resume)"
454454 }
455455 return "send: " + err.Error()
456456 }
457457
458-func (d *swarmDialog) refresh() {
458+func (d *subagentsDialog) refresh() {
459459 if d.snapshot == nil {
460460 d.rows = nil
461461 return
@@ -472,7 +472,7 @@ func (d *swarmDialog) refresh() {
472472 // HandleKey advances state. Returns (closed, statusMsg, statusErr).
473473 // statusMsg / statusErr surface in the host's status bar so the user
474474 // gets feedback on kill/remove without writing to the chat.
475-func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
475+func (d *subagentsDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
476476 if !d.Active() {
477477 return false, "", ""
478478 }
@@ -519,7 +519,7 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
519519 // can start typing immediately. Skipped for non-running
520520 // agents; their inbox isn't listening, so an editor
521521 // would just frustrate them with bounced sends.
522- if a := d.selected(); a != nil && a.Status == swarm.StatusRunning && d.send != nil {
522+ if a := d.selected(); a != nil && a.Status == subagents.StatusRunning && d.send != nil {
523523 d.openPromptEditor(tui.Theme{}, a.ID)
524524 }
525525 }
@@ -534,7 +534,7 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
534534 // agent will run against was already captured via
535535 // SetCurrentModel from the host's /model selection;
536536 // the user changes models the normal way (/model)
537- // before opening /swarm.
537+ // before opening /subagents.
538538 d.openSpawnEditor(tui.Theme{})
539539 }
540540 case 'p':
@@ -544,7 +544,7 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
544544 // failed / killed agent fails with a long ECONNREFUSED
545545 // or ENOENT trail that's not actionable. Tell the user
546546 // what to do instead.
547- if a.Status != swarm.StatusRunning {
547+ if a.Status != subagents.StatusRunning {
548548 return false, "", promptDisabledHint(a.Status)
549549 }
550550 d.openPromptEditor(tui.Theme{}, a.ID)
@@ -565,10 +565,10 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
565565 if a := d.selected(); a != nil && d.stop != nil {
566566 // Only running / pending agents have something to
567567 // cancel. Detached and already-terminal agents would
568- // be silent no-ops at the Swarm layer; surface a hint
568+ // be silent no-ops at the Supervisor layer; surface a hint
569569 // so the user knows kill did nothing and what to do
570570 // instead (remove via 'r').
571- if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending {
571+ if a.Status != subagents.StatusRunning && a.Status != subagents.StatusPending {
572572 return false, "", killDisabledHint(a.Status)
573573 }
574574 if err := d.stop(a.ID); err != nil {
@@ -603,14 +603,14 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
603603 // suggest, arrow keys for caret movement, etc.). Submits stay in
604604 // the view and clear the editor for the next message instead of
605605 // closing it — see handlePromptKey for the always-on behaviour.
606-func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg string) {
606+func (d *subagentsDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg string) {
607607 a := d.selected()
608608
609609 // Lazy-open the editor on first key into the transcript view, so
610610 // re-entering an agent after switching away picks up cleanly.
611611 // Skipped for non-running agents (their inbox isn't listening;
612612 // sending would just bounce).
613- if a != nil && a.Status == swarm.StatusRunning && d.send != nil && !d.prompting {
613+ if a != nil && a.Status == subagents.StatusRunning && d.send != nil && !d.prompting {
614614 d.openPromptEditor(tui.Theme{}, a.ID)
615615 }
616616
@@ -634,7 +634,7 @@ func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg stri
634634 // Everything else — typed runes, Enter, Backspace, arrow keys
635635 // for caret movement, @-file picker — is editor input. If the
636636 // agent isn't running there's no live editor; surface a hint.
637- if a == nil || a.Status != swarm.StatusRunning || d.send == nil {
637+ if a == nil || a.Status != subagents.StatusRunning || d.send == nil {
638638 if k.Kind == tui.KeyRune && a != nil {
639639 return false, "", promptDisabledHint(a.Status)
640640 }
@@ -644,10 +644,10 @@ func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg stri
644644 }
645645
646646 // openSpawnEditor enters the inline new-agent prompt. It deliberately
647-// uses tui.Editor (not a one-line string) so swarm-new supports the same
647+// uses tui.Editor (not a one-line string) so subagent-new supports the same
648648 // editing model as the normal input: multiline paste collapse, drag-drop
649649 // file/dir chips, cursor movement, and the same accent-bar prompt style.
650-func (d *swarmDialog) openSpawnEditor(th tui.Theme) {
650+func (d *subagentsDialog) openSpawnEditor(th tui.Theme) {
651651 d.spawning = true
652652 d.newTaskEd = tui.NewEditor(d.editorPrompt(th))
653653 d.fileSuggest = newFileSuggester()
@@ -662,7 +662,7 @@ func (d *swarmDialog) openSpawnEditor(th tui.Theme) {
662662 // submits "/model" from inside the spawn editor; on select we
663663 // capture the new pendingModel/Provider and reopen the spawn
664664 // editor with the previously-typed task buffer restored.
665-func (d *swarmDialog) openModelPicker() {
665+func (d *subagentsDialog) openModelPicker() {
666666 d.spawning = false
667667 d.newTaskEd = nil
668668 d.fileSuggest = nil
@@ -677,7 +677,7 @@ func (d *swarmDialog) openModelPicker() {
677677 // pendingModel + pendingProvider; Esc leaves them unchanged (the
678678 // user gets to keep whatever was inherited before they invoked
679679 // /model).
680-func (d *swarmDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg string) {
680+func (d *subagentsDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg string) {
681681 if d.modelPicker == nil {
682682 d.openModelPicker()
683683 }
@@ -738,7 +738,7 @@ func stripSlashModelLine(buf string) string {
738738 // openPromptEditor enters the inline "send a follow-up prompt" editor
739739 // for the agent with id targetID. Mirrors openSpawnEditor so the
740740 // editing experience (paste, @-picker, drop chips) is identical.
741-func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) {
741+func (d *subagentsDialog) openPromptEditor(th tui.Theme, targetID string) {
742742 d.prompting = true
743743 d.promptEd = tui.NewEditor(d.editorPrompt(th))
744744 d.promptSuggest = newFileSuggester()
@@ -751,7 +751,7 @@ func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) {
751751 // with the captured target id instead of d.spawn. Esc cancels and
752752 // returns the user to whichever view (list or transcript) was active
753753 // when 'p' was pressed.
754-func (d *swarmDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg string) {
754+func (d *subagentsDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg string) {
755755 if d.promptEd == nil {
756756 d.openPromptEditor(tui.Theme{}, d.promptTargetID)
757757 }
@@ -868,7 +868,7 @@ func (d *swarmDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg strin
868868
869869 // closePromptEditor resets all prompt-editor state. Called on Esc and
870870 // after submit so a subsequent 'p' starts from a clean slate.
871-func (d *swarmDialog) closePromptEditor() {
871+func (d *subagentsDialog) closePromptEditor() {
872872 d.prompting = false
873873 d.promptEd = nil
874874 d.promptSuggest = nil
@@ -878,7 +878,7 @@ func (d *swarmDialog) closePromptEditor() {
878878 // handleSpawnKey owns the keystrokes while the inline new-agent editor
879879 // is open. It mirrors the normal input's @ file-picker path and submit
880880 // expansion, then asks the host to spawn.
881-func (d *swarmDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string) {
881+func (d *subagentsDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string) {
882882 if d.newTaskEd == nil {
883883 d.openSpawnEditor(tui.Theme{})
884884 }
@@ -986,7 +986,7 @@ func (d *swarmDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string
986986 return false, "", ""
987987 }
988988
989-func (d *swarmDialog) selected() *swarm.AgentSnapshot {
989+func (d *subagentsDialog) selected() *subagents.AgentSnapshot {
990990 if d.cursor < 0 || d.cursor >= len(d.rows) {
991991 return nil
992992 }
@@ -996,7 +996,7 @@ func (d *swarmDialog) selected() *swarm.AgentSnapshot {
996996
997997 // Render returns the dialog lines. Re-reads the snapshot each call so
998998 // the dashboard updates as agents make progress.
999-func (d *swarmDialog) Render(th tui.Theme, width int) []string {
999+func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
10001000 if !d.Active() {
10011001 return nil
10021002 }
@@ -1006,7 +1006,7 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string {
10061006 return d.renderTranscript(th, width)
10071007 }
10081008
1009- out := []string{frameHeader(th, "swarm (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)}
1009+ out := []string{frameHeader(th, "subagents (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)}
10101010 if d.prompting {
10111011 return d.renderPromptEditor(th, width, out)
10121012 }
@@ -1061,17 +1061,17 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string {
10611061 return out
10621062 }
10631063 if len(d.rows) == 0 {
1064- out = append(out, " "+th.FG256(th.Muted, "no agents — press n to spawn one (or /swarm new <task>)"))
1064+ out = append(out, " "+th.FG256(th.Muted, "no agents — press n to spawn one (or /subagents new <task>)"))
10651065 out = append(out, frameRule(th, width))
10661066 return out
10671067 }
10681068
10691069 // Column header for readability.
1070- header := fmt.Sprintf(" %-9s %-26s %-8s %s", "STATUS", "ID", "AGE", "ACTIVITY")
1070+ header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")
10711071 out = append(out, th.FG256(th.Muted, header))
10721072
10731073 for i, r := range d.rows {
1074- row := formatSwarmRow(r, width-2)
1074+ row := formatSupervisorRow(r, width-2)
10751075 if i == d.cursor {
10761076 out = append(out, th.PadHighlight(" "+row, width))
10771077 } else {
@@ -1082,14 +1082,14 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string {
10821082 return out
10831083 }
10841084
1085-func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {
1085+func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string {
10861086 a := d.selected()
10871087 if a == nil {
10881088 d.viewing = false
10891089 return d.Render(th, width)
10901090 }
10911091 header := []string{
1092- frameHeader(th, "swarm: "+a.ID+" (type to send, esc back)", width),
1092+ frameHeader(th, "subagent: "+a.ID+" (type to send, esc back)", width),
10931093 " " + th.FG256(th.Muted, "task: "+a.Task),
10941094 " " + th.FG256(th.Muted, "dir: "+a.Dir),
10951095 " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),
@@ -1141,7 +1141,7 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {
11411141 // That gives the user the chat UX they expect (transcript grows
11421142 // with the window, arrow keys belong to the editor, no "↑ N
11431143 // more above" cliff).
1144- body := renderSwarmTranscriptBlocks(raw, th, width, d.compactMode)
1144+ body := renderSupervisorTranscriptBlocks(raw, th, width, d.compactMode)
11451145
11461146 out := header
11471147 out = append(out, body...)
@@ -1161,11 +1161,11 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {
11611161 // row, the editor itself, another breathing row, and a single muted
11621162 // hint. For non-running agents we render a short "can't send while
11631163 // X" note instead so the user knows why typing is disabled.
1164-func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width int, a *swarm.AgentSnapshot) []string {
1164+func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, width int, a *subagents.AgentSnapshot) []string {
11651165 if a == nil {
11661166 return out
11671167 }
1168- if a.Status != swarm.StatusRunning || d.send == nil {
1168+ if a.Status != subagents.StatusRunning || d.send == nil {
11691169 out = append(out, "")
11701170 out = append(out, " "+th.FG256(th.Muted, promptDisabledHint(a.Status)))
11711171 return out
@@ -1227,11 +1227,11 @@ func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width i
12271227 // suppress the spinner so transient / unfamiliar activity strings
12281228 // still animate. Detached / done / failed / killed never animate —
12291229 // they aren't actively producing output.
1230-func agentIsBusy(a *swarm.AgentSnapshot) bool {
1230+func agentIsBusy(a *subagents.AgentSnapshot) bool {
12311231 if a == nil {
12321232 return false
12331233 }
1234- if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending {
1234+ if a.Status != subagents.StatusRunning && a.Status != subagents.StatusPending {
12351235 return false
12361236 }
12371237 switch strings.TrimSpace(a.Activity) {
@@ -1241,7 +1241,7 @@ func agentIsBusy(a *swarm.AgentSnapshot) bool {
12411241 return true
12421242 }
12431243
1244-// renderSwarmTranscriptBlocks converts the agent's flat transcript
1244+// renderSupervisorTranscriptBlocks converts the agent's flat transcript
12451245 // log into a slice of display rows styled like the main chat: user
12461246 // echoes in bubble rows, assistant prose through tui.RenderMarkdown,
12471247 // stderr / error chatter tinted. Each output row is fully indented
@@ -1252,7 +1252,7 @@ func agentIsBusy(a *swarm.AgentSnapshot) bool {
12521252 // whole block together — essential for markdown, which needs the
12531253 // full message body in one piece to detect fenced code spans,
12541254 // bullet lists, and so on.
1255-func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compactMode bool) []string {
1255+func renderSupervisorTranscriptBlocks(lines []string, th tui.Theme, width int, compactMode bool) []string {
12561256 type blockKind int
12571257 const (
12581258 kindAssistant blockKind = iota
@@ -1342,7 +1342,7 @@ func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compac
13421342 // the pre-built header (frame header for list view, or the agent
13431343 // metadata block for transcript view); we append onto it and close
13441344 // with the standard frame rule.
1345-func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) []string {
1345+func (d *subagentsDialog) renderPromptEditor(th tui.Theme, width int, out []string) []string {
13461346 if d.promptEd == nil {
13471347 d.openPromptEditor(th, d.promptTargetID)
13481348 }
@@ -1375,17 +1375,20 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string)
13751375 return out
13761376 }
13771377
1378-// formatSwarmRow is the one-line summary shown per agent.
1378+// formatSupervisorRow is the one-line summary shown per agent.
13791379 //
13801380 // Layout (fixed-width columns, then free-form activity):
13811381 //
13821382 // STATUS ID AGE ACTIVITY
13831383 // ● run fix-login-12345 3m editing main.go
13841384 // ✓ done write-tests-67890 1h done
1385-func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {
1385+func formatSupervisorRow(r subagents.AgentSnapshot, maxWidth int) string {
13861386 status := statusLabel(r.Status)
1387+ if r.ProcessState != "" && r.TurnState != "" {
1388+ status = truncateLineSafe(status+" "+string(r.ProcessState)+"/"+string(r.TurnState), 18)
1389+ }
13871390 age := formatAge(r.Started)
1388- left := fmt.Sprintf("%-9s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age)
1391+ left := fmt.Sprintf("%-18s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age)
13891392 room := maxWidth - len([]rune(left))
13901393 if room < 10 {
13911394 room = 10
@@ -1411,19 +1414,19 @@ func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {
14111414
14121415 // statusLabel returns a short, padded status badge. Kept stable across
14131416 // runs (no colours embedded) so tests can match it deterministically.
1414-func statusLabel(s swarm.Status) string {
1417+func statusLabel(s subagents.Status) string {
14151418 switch s {
1416- case swarm.StatusPending:
1419+ case subagents.StatusPending:
14171420 return "● pend"
1418- case swarm.StatusRunning:
1421+ case subagents.StatusRunning:
14191422 return "● run"
1420- case swarm.StatusDone:
1423+ case subagents.StatusDone:
14211424 return "✓ done"
1422- case swarm.StatusFailed:
1425+ case subagents.StatusFailed:
14231426 return "✗ fail"
1424- case swarm.StatusKilled:
1427+ case subagents.StatusKilled:
14251428 return "■ kill"
1426- case swarm.StatusDetached:
1429+ case subagents.StatusDetached:
14271430 return "○ detach"
14281431 }
14291432 return string(s)
R packages/agent/modes/swarm_dialog_test.go → packages/agent/modes/subagents_dialog_test.go +170 -170 renamed, 79% similar
@@ -9,23 +9,23 @@ import (
99 "testing"
1010 "time"
1111
12- "github.com/patriceckhart/zot/packages/agent/swarm"
12+ "github.com/patriceckhart/zot/packages/agent/subagents"
1313 "github.com/patriceckhart/zot/packages/provider"
1414 "github.com/patriceckhart/zot/packages/tui"
1515 )
1616
1717 // staticSnapshots returns a deterministic snapshot slice for the
1818 // dialog to render.
19-func staticSnapshots(rows ...swarm.AgentSnapshot) func() []swarm.AgentSnapshot {
20- return func() []swarm.AgentSnapshot {
21- out := make([]swarm.AgentSnapshot, len(rows))
19+func staticSnapshots(rows ...subagents.AgentSnapshot) func() []subagents.AgentSnapshot {
20+ return func() []subagents.AgentSnapshot {
21+ out := make([]subagents.AgentSnapshot, len(rows))
2222 copy(out, rows)
2323 return out
2424 }
2525 }
2626
27-func TestSwarmDialogEmptyState(t *testing.T) {
28- d := newSwarmDialog()
27+func TestSubagentsDialogEmptyState(t *testing.T) {
28+ d := newSubagentsDialog()
2929 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
3030 lines := d.Render(tui.Theme{}, 80)
3131 joined := strings.Join(lines, "\n")
@@ -37,13 +37,13 @@ func TestSwarmDialogEmptyState(t *testing.T) {
3737 }
3838 }
3939
40-func TestSwarmDialogRendersRows(t *testing.T) {
40+func TestSubagentsDialogRendersRows(t *testing.T) {
4141 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},
42+ rows := []subagents.AgentSnapshot{
43+ {ID: "alpha-1", Task: "fix login", Status: subagents.StatusRunning, Activity: "editing file", Started: now},
44+ {ID: "beta-2", Task: "write tests", Status: subagents.StatusDone, Activity: "done", Started: now},
4545 }
46- d := newSwarmDialog()
46+ d := newSubagentsDialog()
4747 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
4848 out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
4949 for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} {
@@ -53,14 +53,14 @@ func TestSwarmDialogRendersRows(t *testing.T) {
5353 }
5454 }
5555
56-func TestSwarmDialogStopAndRemoveCallbacks(t *testing.T) {
56+func TestSubagentsDialogStopAndRemoveCallbacks(t *testing.T) {
5757 now := time.Now()
58- rows := []swarm.AgentSnapshot{
59- {ID: "alpha-1", Status: swarm.StatusRunning, Started: now},
58+ rows := []subagents.AgentSnapshot{
59+ {ID: "alpha-1", Status: subagents.StatusRunning, Started: now},
6060 }
6161 stops := 0
6262 removes := 0
63- d := newSwarmDialog()
63+ d := newSubagentsDialog()
6464 d.Open(
6565 staticSnapshots(rows...),
6666 func(id string) error { stops++; return nil },
@@ -90,12 +90,12 @@ func TestSwarmDialogStopAndRemoveCallbacks(t *testing.T) {
9090 }
9191 }
9292
93-func TestSwarmDialogEnterShowsTranscript(t *testing.T) {
93+func TestSubagentsDialogEnterShowsTranscript(t *testing.T) {
9494 now := time.Now()
95- rows := []swarm.AgentSnapshot{
96- {ID: "alpha-1", Task: "do stuff", Status: swarm.StatusDone, Tail: "line a\nline b", Started: now},
95+ rows := []subagents.AgentSnapshot{
96+ {ID: "alpha-1", Task: "do stuff", Status: subagents.StatusDone, Tail: "line a\nline b", Started: now},
9797 }
98- d := newSwarmDialog()
98+ d := newSubagentsDialog()
9999 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
100100 _ = d.Render(tui.Theme{}, 80)
101101 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
@@ -113,8 +113,8 @@ func TestSwarmDialogEnterShowsTranscript(t *testing.T) {
113113 }
114114 }
115115
116-func TestSwarmDialogEscClosesList(t *testing.T) {
117- d := newSwarmDialog()
116+func TestSubagentsDialogEscClosesList(t *testing.T) {
117+ d := newSubagentsDialog()
118118 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
119119 closed, _, _ := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
120120 if !closed {
@@ -125,12 +125,12 @@ func TestSwarmDialogEscClosesList(t *testing.T) {
125125 }
126126 }
127127
128-// TestSwarmDialogSpawnFlow drives the inline n-prompt end-to-end:
128+// TestSubagentsDialogSpawnFlow drives the inline n-prompt end-to-end:
129129 // press n, type a task, press enter, observe the spawn callback
130130 // fires with the right text and the prompt closes.
131-func TestSwarmDialogSpawnFlow(t *testing.T) {
131+func TestSubagentsDialogSpawnFlow(t *testing.T) {
132132 var spawned string
133- d := newSwarmDialog()
133+ d := newSubagentsDialog()
134134 d.Open(
135135 staticSnapshots(),
136136 nil, nil,
@@ -173,13 +173,13 @@ func TestSwarmDialogSpawnFlow(t *testing.T) {
173173 }
174174 }
175175
176-func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) {
176+func TestSubagentsDialogSpawnFilePickerFlow(t *testing.T) {
177177 cwd := t.TempDir()
178178 if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil {
179179 t.Fatal(err)
180180 }
181181 var spawned string
182- d := newSwarmDialog()
182+ d := newSubagentsDialog()
183183 d.Open(staticSnapshots(), nil, nil, func(task, _, _ string) error { spawned = task; return nil }, nil, nil, cwd)
184184 _ = d.Render(tui.Theme{}, 80)
185185
@@ -200,7 +200,7 @@ func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) {
200200 }
201201
202202 // A second Enter submits. The chip should expand to the absolute
203- // path before the task reaches Swarm.Spawn.
203+ // path before the task reaches Supervisor.Spawn.
204204 _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
205205 if errMsg != "" {
206206 t.Fatalf("unexpected err: %s", errMsg)
@@ -214,9 +214,9 @@ func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) {
214214 }
215215 }
216216
217-func TestSwarmDialogSpawnEscCancels(t *testing.T) {
217+func TestSubagentsDialogSpawnEscCancels(t *testing.T) {
218218 called := false
219- d := newSwarmDialog()
219+ d := newSubagentsDialog()
220220 d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { called = true; return nil }, nil, nil, "")
221221 _ = d.Render(tui.Theme{}, 80)
222222
@@ -236,8 +236,8 @@ func TestSwarmDialogSpawnEscCancels(t *testing.T) {
236236 }
237237 }
238238
239-func TestSwarmDialogNoNWithoutSpawnCallback(t *testing.T) {
240- d := newSwarmDialog()
239+func TestSubagentsDialogNoNWithoutSpawnCallback(t *testing.T) {
240+ d := newSubagentsDialog()
241241 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") // spawn callback nil
242242 _ = d.Render(tui.Theme{}, 80)
243243 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
@@ -246,23 +246,23 @@ func TestSwarmDialogNoNWithoutSpawnCallback(t *testing.T) {
246246 }
247247 }
248248
249-// TestSwarmDialogFullTranscriptRendersEverything pins the /btw-style
249+// TestSubagentsDialogFullTranscriptRendersEverything pins the /btw-style
250250 // rendering: the dialog NEVER clips the transcript into a fixed
251251 // window with internal scroll. Every line goes into the rendered
252252 // output and the host's outer scrollback owns overflow. This is
253253 // what makes the transcript grow with the window and with the
254254 // content, instead of cutting off at row 16 with a "↑ N more above"
255255 // indicator.
256-func TestSwarmDialogFullTranscriptRendersEverything(t *testing.T) {
256+func TestSubagentsDialogFullTranscriptRendersEverything(t *testing.T) {
257257 lines := make([]string, 60)
258258 for i := range lines {
259259 lines[i] = fmt.Sprintf("line %d", i)
260260 }
261- rows := []swarm.AgentSnapshot{{
262- ID: "alpha-1", Task: "x", Status: swarm.StatusDone,
261+ rows := []subagents.AgentSnapshot{{
262+ ID: "alpha-1", Task: "x", Status: subagents.StatusDone,
263263 Lines: lines, Started: time.Now(),
264264 }}
265- d := newSwarmDialog()
265+ d := newSubagentsDialog()
266266 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
267267 _ = d.Render(tui.Theme{}, 80)
268268 d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open viewer
@@ -289,16 +289,16 @@ func TestSwarmDialogFullTranscriptRendersEverything(t *testing.T) {
289289 }
290290 }
291291
292-// TestSwarmDialogOpenViewingJumpsToAgent regression-tests /swarm logs
292+// TestSubagentsDialogOpenViewingJumpsToAgent regression-tests /subagents logs
293293 // <id>: OpenViewing should select the row whose id matches the
294294 // (possibly truncated) argument and enter the transcript view
295295 // directly without showing the list first.
296-func TestSwarmDialogOpenViewingJumpsToAgent(t *testing.T) {
297- rows := []swarm.AgentSnapshot{
296+func TestSubagentsDialogOpenViewingJumpsToAgent(t *testing.T) {
297+ rows := []subagents.AgentSnapshot{
298298 {ID: "alpha-1", Lines: []string{"hi from alpha"}, Started: time.Now()},
299299 {ID: "beta-2", Lines: []string{"hi from beta"}, Started: time.Now()},
300300 }
301- d := newSwarmDialog()
301+ d := newSubagentsDialog()
302302 ok := d.OpenViewing("beta", staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
303303 if !ok {
304304 t.Fatal("prefix match did not resolve")
@@ -312,24 +312,24 @@ func TestSwarmDialogOpenViewingJumpsToAgent(t *testing.T) {
312312 }
313313 }
314314
315-func TestSwarmDialogOpenViewingUnknownIDReturnsFalse(t *testing.T) {
316- d := newSwarmDialog()
315+func TestSubagentsDialogOpenViewingUnknownIDReturnsFalse(t *testing.T) {
316+ d := newSubagentsDialog()
317317 ok := d.OpenViewing("nope", staticSnapshots(), nil, nil, nil, nil, nil, "")
318318 if ok {
319319 t.Fatal("expected miss for unknown id")
320320 }
321321 }
322322
323-// TestSwarmDialogPromptFlowFromList drives the inline p-prompt:
323+// TestSubagentsDialogPromptFlowFromList drives the inline p-prompt:
324324 // press p on the selected agent, type a message, press enter,
325325 // 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()},
326+func TestSubagentsDialogPromptFlowFromList(t *testing.T) {
327+ rows := []subagents.AgentSnapshot{
328+ {ID: "alpha-1", Status: subagents.StatusRunning, Started: time.Now()},
329329 }
330330 var gotID, gotText string
331331 sends := 0
332- d := newSwarmDialog()
332+ d := newSubagentsDialog()
333333 d.Open(
334334 staticSnapshots(rows...),
335335 nil, nil, nil,
@@ -371,18 +371,18 @@ func TestSwarmDialogPromptFlowFromList(t *testing.T) {
371371 }
372372 }
373373
374-// TestSwarmDialogPromptFromTranscriptView pins the always-on editor
374+// TestSubagentsDialogPromptFromTranscriptView pins the always-on editor
375375 // behaviour for the transcript view: entering an agent immediately
376376 // auto-opens the inline composer (no `p` needed), typing flows
377377 // straight into it, Enter sends, and the metadata stays visible.
378378 // The transcript view is /btw-style now — you never leave the
379379 // 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"},
380+func TestSubagentsDialogPromptFromTranscriptView(t *testing.T) {
381+ rows := []subagents.AgentSnapshot{
382+ {ID: "beta-2", Task: "do thing", Status: subagents.StatusRunning, Started: time.Now(), Tail: "hi from beta"},
383383 }
384384 var gotID, gotText string
385- d := newSwarmDialog()
385+ d := newSubagentsDialog()
386386 d.Open(
387387 staticSnapshots(rows...),
388388 nil, nil, nil,
@@ -404,7 +404,7 @@ func TestSwarmDialogPromptFromTranscriptView(t *testing.T) {
404404 if !strings.Contains(out, "task: do thing") {
405405 t.Fatalf("transcript metadata not visible:\n%s", out)
406406 }
407- if !strings.Contains(out, "swarm: beta-2") {
407+ if !strings.Contains(out, "subagent: beta-2") {
408408 t.Fatalf("frame header missing agent id:\n%s", out)
409409 }
410410
@@ -430,12 +430,12 @@ func TestSwarmDialogPromptFromTranscriptView(t *testing.T) {
430430 }
431431 }
432432
433-// TestSwarmDialogPromptEscCancels makes sure Esc inside the prompt
433+// TestSubagentsDialogPromptEscCancels makes sure Esc inside the prompt
434434 // 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()}}
435+func TestSubagentsDialogPromptEscCancels(t *testing.T) {
436+ rows := []subagents.AgentSnapshot{{ID: "gamma-3", Status: subagents.StatusRunning, Started: time.Now()}}
437437 sends := 0
438- d := newSwarmDialog()
438+ d := newSubagentsDialog()
439439 d.Open(
440440 staticSnapshots(rows...),
441441 nil, nil, nil,
@@ -457,12 +457,12 @@ func TestSwarmDialogPromptEscCancels(t *testing.T) {
457457 }
458458 }
459459
460-// TestSwarmDialogPWithoutSendCallback verifies that pressing 'p'
460+// TestSubagentsDialogPWithoutSendCallback verifies that pressing 'p'
461461 // when no send callback is wired is a silent no-op (mirrors the
462462 // 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()
463+func TestSubagentsDialogPWithoutSendCallback(t *testing.T) {
464+ rows := []subagents.AgentSnapshot{{ID: "delta-4", Status: subagents.StatusRunning, Started: time.Now()}}
465+ d := newSubagentsDialog()
466466 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
467467 _ = d.Render(tui.Theme{}, 80)
468468 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
@@ -476,13 +476,13 @@ func TestSwarmDialogPWithoutSendCallback(t *testing.T) {
476476 // dialog opens with the cursor on the first detached row (skipping
477477 // any leading running ones) so the user can press R immediately.
478478 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()},
479+ rows := []subagents.AgentSnapshot{
480+ {ID: "live-1", Status: subagents.StatusRunning, Started: time.Now()},
481+ {ID: "live-2", Status: subagents.StatusRunning, Started: time.Now()},
482+ {ID: "detached-1", Status: subagents.StatusDetached, Started: time.Now()},
483+ {ID: "detached-2", Status: subagents.StatusKilled, Started: time.Now()},
484484 }
485- d := newSwarmDialog()
485+ d := newSubagentsDialog()
486486 count := d.OpenForResume(
487487 staticSnapshots(rows...),
488488 nil, nil, nil, nil,
@@ -506,10 +506,10 @@ func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) {
506506 // slash handler is responsible for surfacing the "no resumable
507507 // agents" status string — the dialog itself just opens.
508508 func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) {
509- rows := []swarm.AgentSnapshot{
510- {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()},
509+ rows := []subagents.AgentSnapshot{
510+ {ID: "live-1", Status: subagents.StatusRunning, Started: time.Now()},
511511 }
512- d := newSwarmDialog()
512+ d := newSubagentsDialog()
513513 count := d.OpenForResume(
514514 staticSnapshots(rows...),
515515 nil, nil, nil, nil,
@@ -528,15 +528,15 @@ func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) {
528528 }
529529
530530 // TestOpenForResumeREnterTriggersResume drives the full picker UX:
531-// /swarm resume → dialog opens parked on a resumable row → user
531+// /subagents resume → dialog opens parked on a resumable row → user
532532 // presses R → the resume callback fires for that exact id.
533533 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()},
534+ rows := []subagents.AgentSnapshot{
535+ {ID: "live-1", Status: subagents.StatusRunning, Started: time.Now()},
536+ {ID: "detached-1", Status: subagents.StatusDetached, Started: time.Now()},
537537 }
538538 var resumed string
539- d := newSwarmDialog()
539+ d := newSubagentsDialog()
540540 d.OpenForResume(
541541 staticSnapshots(rows...),
542542 nil, nil, nil, nil,
@@ -561,7 +561,7 @@ func TestOpenForResumeREnterTriggersResume(t *testing.T) {
561561 // the way of the inline editors (which need the cursor blink).
562562 func TestNeedsTickRefresh(t *testing.T) {
563563 // Closed.
564- d := newSwarmDialog()
564+ d := newSubagentsDialog()
565565 if d.NeedsTickRefresh() {
566566 t.Fatal("closed dialog should not request tick refresh")
567567 }
@@ -573,7 +573,7 @@ func TestNeedsTickRefresh(t *testing.T) {
573573 }
574574
575575 // Transcript view: still background-driven; tick should refresh.
576- rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}}
576+ rows := []subagents.AgentSnapshot{{ID: "a", Status: subagents.StatusRunning, Started: time.Now()}}
577577 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
578578 _ = d.Render(tui.Theme{}, 80)
579579 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
@@ -585,7 +585,7 @@ func TestNeedsTickRefresh(t *testing.T) {
585585 }
586586
587587 // Spawn editor open: tick should NOT refresh (cursor blink).
588- d = newSwarmDialog()
588+ d = newSubagentsDialog()
589589 d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "")
590590 _ = d.Render(tui.Theme{}, 80)
591591 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
@@ -597,8 +597,8 @@ func TestNeedsTickRefresh(t *testing.T) {
597597 }
598598
599599 // Prompt editor open: same suppression.
600- d = newSwarmDialog()
601- rows = []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}}
600+ d = newSubagentsDialog()
601+ rows = []subagents.AgentSnapshot{{ID: "a", Status: subagents.StatusRunning, Started: time.Now()}}
602602 d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "")
603603 _ = d.Render(tui.Theme{}, 80)
604604 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
@@ -616,12 +616,12 @@ func TestNeedsTickRefresh(t *testing.T) {
616616 // stayed at the (now-hidden) main editor row when the user
617617 // pressed 'p', so the visible caret looked wrong.
618618 func TestCursorPosPromptEditor(t *testing.T) {
619- rows := []swarm.AgentSnapshot{
620- {ID: "a", Task: "t", Status: swarm.StatusRunning, Started: time.Now()},
619+ rows := []subagents.AgentSnapshot{
620+ {ID: "a", Task: "t", Status: subagents.StatusRunning, Started: time.Now()},
621621 }
622622
623623 // From the list view.
624- d := newSwarmDialog()
624+ d := newSubagentsDialog()
625625 d.Open(staticSnapshots(rows...), nil, nil, nil,
626626 func(string, string) error { return nil }, nil, "")
627627 _ = d.Render(tui.Theme{}, 80)
@@ -635,7 +635,7 @@ func TestCursorPosPromptEditor(t *testing.T) {
635635 }
636636
637637 // From the transcript view (deeper offset due to metadata block).
638- d = newSwarmDialog()
638+ d = newSubagentsDialog()
639639 d.Open(staticSnapshots(rows...), nil, nil, nil,
640640 func(string, string) error { return nil }, nil, "")
641641 _ = d.Render(tui.Theme{}, 80)
@@ -651,7 +651,7 @@ func TestCursorPosPromptEditor(t *testing.T) {
651651 }
652652
653653 func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T) {
654- d := newSwarmDialog()
654+ d := newSubagentsDialog()
655655 d.SetCurrentModel("claude-sonnet-4-5", "anthropic")
656656 d.Open(staticSnapshots(), nil, nil, func(task, model, provider string) error { return nil }, nil, nil, "")
657657 _ = d.Render(tui.Theme{}, 80)
@@ -670,19 +670,19 @@ func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T)
670670 }
671671 }
672672
673-// TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression
673+// TestSubagentsDialogPOnNonRunningAgentShowsHint covers the regression
674674 // caught by the user's screenshot: pressing 'p' on a detached agent
675675 // dialled a dead unix socket and printed the raw path as an error.
676676 // We now block the prompt editor for any non-running status and
677677 // surface a clear status-bar hint instead.
678-func TestSwarmDialogPOnNonRunningAgentShowsHint(t *testing.T) {
679- for _, st := range []swarm.Status{
680- swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,
678+func TestSubagentsDialogPOnNonRunningAgentShowsHint(t *testing.T) {
679+ for _, st := range []subagents.Status{
680+ subagents.StatusDetached, subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled,
681681 } {
682682 t.Run(string(st), func(t *testing.T) {
683- rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}}
683+ rows := []subagents.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}}
684684 sends := 0
685- d := newSwarmDialog()
685+ d := newSubagentsDialog()
686686 d.Open(staticSnapshots(rows...), nil, nil, nil,
687687 func(string, string) error { sends++; return nil }, nil, "")
688688 _ = d.Render(tui.Theme{}, 80)
@@ -700,19 +700,19 @@ func TestSwarmDialogPOnNonRunningAgentShowsHint(t *testing.T) {
700700 if !strings.Contains(errMsg, string(st)) {
701701 t.Errorf("hint = %q; should mention status %q", errMsg, st)
702702 }
703- if !strings.Contains(errMsg, "resume") && st != swarm.StatusPending && st != swarm.StatusRunning {
703+ if !strings.Contains(errMsg, "resume") && st != subagents.StatusPending && st != subagents.StatusRunning {
704704 t.Errorf("hint = %q; should mention resume for resumable state", errMsg)
705705 }
706706 })
707707 }
708708 }
709709
710-// TestSwarmDialogPOnRunningAgentOpensEditor is the positive
710+// TestSubagentsDialogPOnRunningAgentOpensEditor is the positive
711711 // complement of the previous test: running agents still open the
712712 // editor normally.
713-func TestSwarmDialogPOnRunningAgentOpensEditor(t *testing.T) {
714- rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}}
715- d := newSwarmDialog()
713+func TestSubagentsDialogPOnRunningAgentOpensEditor(t *testing.T) {
714+ rows := []subagents.AgentSnapshot{{ID: "a", Status: subagents.StatusRunning, Started: time.Now()}}
715+ d := newSubagentsDialog()
716716 d.Open(staticSnapshots(rows...), nil, nil, nil,
717717 func(string, string) error { return nil }, nil, "")
718718 _ = d.Render(tui.Theme{}, 80)
@@ -726,11 +726,11 @@ func TestSwarmDialogPOnRunningAgentOpensEditor(t *testing.T) {
726726 // user-friendly "isn't accepting input" hint, and unknown errors
727727 // fall through verbatim.
728728 func TestFriendlySendErrRewritesNotReady(t *testing.T) {
729- hint := friendlySendErr("alpha-1", swarm.ErrNotReady)
729+ hint := friendlySendErr("alpha-1", subagents.ErrNotReady)
730730 if !strings.Contains(hint, "alpha-1") || !strings.Contains(hint, "resume") {
731731 t.Errorf("friendlySendErr(ErrNotReady) = %q", hint)
732732 }
733- if strings.Contains(hint, "swarm:") {
733+ if strings.Contains(hint, "subagent:") {
734734 t.Errorf("friendlySendErr leaked raw error text: %q", hint)
735735 }
736736
@@ -741,11 +741,11 @@ func TestFriendlySendErrRewritesNotReady(t *testing.T) {
741741 }
742742 }
743743
744-// TestRenderSwarmTranscriptBlocks_GroupsByRole asserts the flat
744+// TestRenderSupervisorTranscriptBlocks_GroupsByRole asserts the flat
745745 // transcript log is regrouped into user / assistant / stderr /
746746 // error blocks before rendering. Each output row should be styled
747747 // for its role rather than printed verbatim.
748-func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) {
748+func TestRenderSupervisorTranscriptBlocks_GroupsByRole(t *testing.T) {
749749 lines := []string{
750750 "user: hello",
751751 "hi there",
@@ -753,7 +753,7 @@ func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) {
753753 "stderr: warning thing",
754754 "error: boom",
755755 }
756- out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false)
756+ out := renderSupervisorTranscriptBlocks(lines, tui.Theme{}, 80, false)
757757 joined := strings.Join(out, "\n")
758758
759759 // The user echo should appear as a bubble row, which contains
@@ -780,17 +780,17 @@ func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) {
780780 }
781781 }
782782
783-// TestRenderSwarmTranscriptBlocks_MarkdownStyling proves the
783+// TestRenderSupervisorTranscriptBlocks_MarkdownStyling proves the
784784 // assistant text goes through tui.RenderMarkdown: a literal **bold**
785785 // pair should become an ANSI bold sequence and bullet "-" should
786786 // become "•".
787-func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) {
787+func TestRenderSupervisorTranscriptBlocks_MarkdownStyling(t *testing.T) {
788788 lines := []string{
789789 "Here is a list:",
790790 "- **bold** item",
791791 "- plain item",
792792 }
793- out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false)
793+ out := renderSupervisorTranscriptBlocks(lines, tui.Theme{}, 80, false)
794794 joined := strings.Join(out, "\n")
795795
796796 // ANSI bold on/off. Markdown rendering uses CSI 1 / 22.
@@ -807,11 +807,11 @@ func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) {
807807 }
808808 }
809809
810-// TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines
810+// TestRenderSupervisorTranscriptBlocks_GroupsAdjacentAssistantLines
811811 // guards the contract that consecutive assistant lines form one
812812 // markdown block. If we render each line in isolation, fenced code
813813 // (and other multi-line constructs) would never close.
814-func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) {
814+func TestRenderSupervisorTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) {
815815 lines := []string{
816816 "Some prose.",
817817 "```go",
@@ -821,7 +821,7 @@ func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T)
821821 "```",
822822 "Done.",
823823 }
824- out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false)
824+ out := renderSupervisorTranscriptBlocks(lines, tui.Theme{}, 80, false)
825825 joined := strings.Join(out, "\n")
826826
827827 // The literal fence markers should be consumed by the markdown
@@ -838,19 +838,19 @@ func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T)
838838 }
839839 }
840840
841-// TestSwarmDialogKOnNonRunningAgentShowsHint mirrors the prompt-
841+// TestSubagentsDialogKOnNonRunningAgentShowsHint mirrors the prompt-
842842 // disabled test: pressing 'k' on an already-terminal or detached
843-// agent now surfaces a hint instead of routing to Swarm.Stop, which
843+// agent now surfaces a hint instead of routing to Supervisor.Stop, which
844844 // is a no-op for those states (and used to segfault for detached
845845 // agents before the cancel-nil guard).
846-func TestSwarmDialogKOnNonRunningAgentShowsHint(t *testing.T) {
847- for _, st := range []swarm.Status{
848- swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,
846+func TestSubagentsDialogKOnNonRunningAgentShowsHint(t *testing.T) {
847+ for _, st := range []subagents.Status{
848+ subagents.StatusDetached, subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled,
849849 } {
850850 t.Run(string(st), func(t *testing.T) {
851- rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}}
851+ rows := []subagents.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}}
852852 stops := 0
853- d := newSwarmDialog()
853+ d := newSubagentsDialog()
854854 d.Open(staticSnapshots(rows...),
855855 func(string) error { stops++; return nil },
856856 nil, nil, nil, nil, "")
@@ -866,14 +866,14 @@ func TestSwarmDialogKOnNonRunningAgentShowsHint(t *testing.T) {
866866 }
867867 }
868868
869-// TestSwarmDialogSpawnFlow_CurrentModelInherited proves the spawn
869+// TestSubagentsDialogSpawnFlow_CurrentModelInherited proves the spawn
870870 // callback receives whatever model the host pinned via
871871 // SetCurrentModel before opening the dialog. This is how /model is
872-// surfaced to swarm agents: the user picks via /model first, then
873-// /swarm n, and the new agent inherits.
874-func TestSwarmDialogSpawnFlow_CurrentModelInherited(t *testing.T) {
872+// surfaced to subagent agents: the user picks via /model first, then
873+// /subagents n, and the new agent inherits.
874+func TestSubagentsDialogSpawnFlow_CurrentModelInherited(t *testing.T) {
875875 var gotTask, gotModel, gotProv string
876- d := newSwarmDialog()
876+ d := newSubagentsDialog()
877877 d.SetCurrentModel("claude-sonnet-4-5", "anthropic")
878878 d.Open(staticSnapshots(), nil, nil,
879879 func(task, model, prov string) error {
@@ -909,14 +909,14 @@ func TestSwarmDialogSpawnFlow_CurrentModelInherited(t *testing.T) {
909909 }
910910 }
911911
912-// TestSwarmDialogSpawnEditor_SlashModelOpensPicker proves the in-
912+// TestSubagentsDialogSpawnEditor_SlashModelOpensPicker proves the in-
913913 // editor /model command pops the same model dialog the global
914914 // /model uses, that picking a model captures it as the pending
915915 // override, and that the spawn editor reopens with any previously-
916916 // typed task text intact so the user doesn't lose work.
917-func TestSwarmDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) {
917+func TestSubagentsDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) {
918918 var gotTask, gotModel, gotProv string
919- d := newSwarmDialog()
919+ d := newSubagentsDialog()
920920 d.SetCurrentModel("old-model", "old-prov")
921921 d.Open(staticSnapshots(), nil, nil,
922922 func(task, model, prov string) error {
@@ -984,12 +984,12 @@ func TestSwarmDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) {
984984 }
985985 }
986986
987-// TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft confirms
987+// TestSubagentsDialogSpawnEditor_SlashModelEscRestoresDraft confirms
988988 // pressing Esc inside the picker (no selection) reopens the spawn
989989 // editor with the previously-typed text intact AND leaves the
990990 // pending model untouched, so the user can bail out cleanly.
991-func TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) {
992- d := newSwarmDialog()
991+func TestSubagentsDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) {
992+ d := newSubagentsDialog()
993993 d.SetCurrentModel("keep-me", "keep-prov")
994994 d.Open(staticSnapshots(), nil, nil,
995995 func(string, string, string) error { return nil },
@@ -1041,13 +1041,13 @@ func TestStripSlashModelLine(t *testing.T) {
10411041 }
10421042 }
10431043
1044-// TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault confirms that
1044+// TestSubagentsDialogSpawnFlow_NoModelInheritsChildDefault confirms that
10451045 // when the host doesn't pin a model (SetCurrentModel never called or
10461046 // called with empty strings), the spawn callback receives empty
10471047 // model/provider and the child subprocess resolves its own default.
1048-func TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) {
1048+func TestSubagentsDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) {
10491049 var gotModel, gotProv string
1050- d := newSwarmDialog()
1050+ d := newSubagentsDialog()
10511051 d.Open(staticSnapshots(), nil, nil,
10521052 func(_, model, prov string) error {
10531053 gotModel, gotProv = model, prov
@@ -1066,20 +1066,20 @@ func TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) {
10661066 }
10671067 }
10681068
1069-// TestSwarmTranscriptGrowsWithNewMessages confirms the /btw-style
1069+// TestSupervisorTranscriptGrowsWithNewMessages confirms the /btw-style
10701070 // "the transcript grows with the content" property: when the agent
10711071 // reply lands in the snapshot, the next render includes it in full,
10721072 // alongside everything that was already there. Replaces the old
10731073 // followTail/scroll tests that pinned a viewport mechanic we no
10741074 // longer have.
1075-func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) {
1076- cur := []swarm.AgentSnapshot{{
1077- ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1075+func TestSupervisorTranscriptGrowsWithNewMessages(t *testing.T) {
1076+ cur := []subagents.AgentSnapshot{{
1077+ ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
10781078 Lines: []string{"old line 1", "old line 2"}, Started: time.Now(),
10791079 }}
1080- snap := func() []swarm.AgentSnapshot { return cur }
1080+ snap := func() []subagents.AgentSnapshot { return cur }
10811081
1082- d := newSwarmDialog()
1082+ d := newSubagentsDialog()
10831083 d.Open(snap, nil, nil, nil,
10841084 func(string, string) error { return nil },
10851085 nil, "")
@@ -1093,8 +1093,8 @@ func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) {
10931093
10941094 // Agent appends a fresh reply: the very next render must show
10951095 // both the new content AND keep the old content (no clipping).
1096- cur = []swarm.AgentSnapshot{{
1097- ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1096+ cur = []subagents.AgentSnapshot{{
1097+ ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
10981098 Lines: []string{"old line 1", "old line 2", "new reply A", "new reply B"},
10991099 Started: cur[0].Started,
11001100 }}
@@ -1106,19 +1106,19 @@ func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) {
11061106 }
11071107 }
11081108
1109-// TestSwarmTranscriptBtwStyleMultipleSends pins the /btw-style flow
1109+// TestSupervisorTranscriptBtwStyleMultipleSends pins the /btw-style flow
11101110 // for the transcript view: after one Enter-submit the editor stays
11111111 // mounted and clears, and a second message can be composed and sent
11121112 // without any modal toggle. Regresses the previous design where
11131113 // every send closed the prompt and forced the user to press `p`
11141114 // again.
1115-func TestSwarmTranscriptBtwStyleMultipleSends(t *testing.T) {
1116- rows := []swarm.AgentSnapshot{{
1117- ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(),
1115+func TestSupervisorTranscriptBtwStyleMultipleSends(t *testing.T) {
1116+ rows := []subagents.AgentSnapshot{{
1117+ ID: "agent-1", Task: "x", Status: subagents.StatusRunning, Started: time.Now(),
11181118 Lines: []string{"warm-up"},
11191119 }}
11201120 var sent []string
1121- d := newSwarmDialog()
1121+ d := newSubagentsDialog()
11221122 d.Open(staticSnapshots(rows...), nil, nil, nil,
11231123 func(_, text string) error { sent = append(sent, text); return nil },
11241124 nil, "")
@@ -1145,18 +1145,18 @@ func TestSwarmTranscriptBtwStyleMultipleSends(t *testing.T) {
11451145 }
11461146 }
11471147
1148-// TestSwarmTranscriptEscClosesView pins the Esc semantics for the
1148+// TestSupervisorTranscriptEscClosesView pins the Esc semantics for the
11491149 // /btw-style transcript view: a single Esc closes the whole view
11501150 // (back to the dashboard list), regardless of whether the editor
11511151 // is currently composing. Without this routing the editor's own
11521152 // Esc handler would just clear the modal and leave the user stuck
11531153 // inside an empty transcript view.
1154-func TestSwarmTranscriptEscClosesView(t *testing.T) {
1155- rows := []swarm.AgentSnapshot{{
1156- ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(),
1154+func TestSupervisorTranscriptEscClosesView(t *testing.T) {
1155+ rows := []subagents.AgentSnapshot{{
1156+ ID: "agent-1", Task: "x", Status: subagents.StatusRunning, Started: time.Now(),
11571157 Lines: []string{"hi"},
11581158 }}
1159- d := newSwarmDialog()
1159+ d := newSubagentsDialog()
11601160 d.Open(staticSnapshots(rows...), nil, nil, nil,
11611161 func(string, string) error { return nil },
11621162 nil, "")
@@ -1175,17 +1175,17 @@ func TestSwarmTranscriptEscClosesView(t *testing.T) {
11751175 }
11761176 }
11771177
1178-// TestSwarmTranscriptNonRunningAgentNoEditor pins the safety net:
1178+// TestSupervisorTranscriptNonRunningAgentNoEditor pins the safety net:
11791179 // transcripts for done / failed / killed agents must NOT auto-open
11801180 // an editor (the agent's inbox isn't listening, so a send would
11811181 // just bounce). The view shows the transcript plus a hint about
11821182 // resuming instead.
1183-func TestSwarmTranscriptNonRunningAgentNoEditor(t *testing.T) {
1184- rows := []swarm.AgentSnapshot{{
1185- ID: "agent-1", Task: "x", Status: swarm.StatusDone, Started: time.Now(),
1183+func TestSupervisorTranscriptNonRunningAgentNoEditor(t *testing.T) {
1184+ rows := []subagents.AgentSnapshot{{
1185+ ID: "agent-1", Task: "x", Status: subagents.StatusDone, Started: time.Now(),
11861186 Lines: []string{"all done"},
11871187 }}
1188- d := newSwarmDialog()
1188+ d := newSubagentsDialog()
11891189 d.Open(staticSnapshots(rows...), nil, nil, nil,
11901190 func(string, string) error { return nil },
11911191 nil, "")
@@ -1200,19 +1200,19 @@ func TestSwarmTranscriptNonRunningAgentNoEditor(t *testing.T) {
12001200 }
12011201 }
12021202
1203-// TestSwarmTranscriptTickRefreshWhileEditing pins the streaming
1203+// TestSupervisorTranscriptTickRefreshWhileEditing pins the streaming
12041204 // fix: with the always-on editor in transcript view, the host's
12051205 // animation tick MUST still call us so streaming agent output
12061206 // renders. Without this, sending a message and watching the reply
12071207 // land silently failed because the prompting=true branch was
12081208 // suppressing redraws (like the modal editors do).
1209-func TestSwarmTranscriptTickRefreshWhileEditing(t *testing.T) {
1210- rows := []swarm.AgentSnapshot{{
1211- ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1209+func TestSupervisorTranscriptTickRefreshWhileEditing(t *testing.T) {
1210+ rows := []subagents.AgentSnapshot{{
1211+ ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
12121212 Activity: "thinking", Started: time.Now(),
12131213 Lines: []string{"hello"},
12141214 }}
1215- d := newSwarmDialog()
1215+ d := newSubagentsDialog()
12161216 d.Open(staticSnapshots(rows...), nil, nil, nil,
12171217 func(string, string) error { return nil },
12181218 nil, "")
@@ -1227,15 +1227,15 @@ func TestSwarmTranscriptTickRefreshWhileEditing(t *testing.T) {
12271227 }
12281228 }
12291229
1230-// TestSwarmTranscriptTickRefreshModalSuppressed pins the inverse:
1230+// TestSupervisorTranscriptTickRefreshModalSuppressed pins the inverse:
12311231 // the list-view `p` modal (prompting without viewing) still
12321232 // suppresses tick like the other modal editors, so the cursor
12331233 // blink works cleanly while composing a one-off prompt.
1234-func TestSwarmTranscriptTickRefreshModalSuppressed(t *testing.T) {
1235- rows := []swarm.AgentSnapshot{{
1236- ID: "agent-1", Status: swarm.StatusRunning, Started: time.Now(),
1234+func TestSupervisorTranscriptTickRefreshModalSuppressed(t *testing.T) {
1235+ rows := []subagents.AgentSnapshot{{
1236+ ID: "agent-1", Status: subagents.StatusRunning, Started: time.Now(),
12371237 }}
1238- d := newSwarmDialog()
1238+ d := newSubagentsDialog()
12391239 d.Open(staticSnapshots(rows...), nil, nil, nil,
12401240 func(string, string) error { return nil },
12411241 nil, "")
@@ -1250,18 +1250,18 @@ func TestSwarmTranscriptTickRefreshModalSuppressed(t *testing.T) {
12501250 }
12511251 }
12521252
1253-// TestSwarmTranscriptBusySpinnerRenders proves the inline spinner
1253+// TestSupervisorTranscriptBusySpinnerRenders proves the inline spinner
12541254 // appears above the always-on editor while the agent is busy and
12551255 // disappears when it goes idle. This is the /btw-style "something
12561256 // is happening" feedback the user was missing.
1257-func TestSwarmTranscriptBusySpinnerRenders(t *testing.T) {
1258- cur := []swarm.AgentSnapshot{{
1259- ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1257+func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
1258+ cur := []subagents.AgentSnapshot{{
1259+ ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
12601260 Activity: "thinking", Started: time.Now(),
12611261 Lines: []string{"hello"},
12621262 }}
1263- snap := func() []swarm.AgentSnapshot { return cur }
1264- d := newSwarmDialog()
1263+ snap := func() []subagents.AgentSnapshot { return cur }
1264+ d := newSubagentsDialog()
12651265 d.Open(snap, nil, nil, nil,
12661266 func(string, string) error { return nil },
12671267 nil, "")
@@ -1289,9 +1289,9 @@ func TestSwarmTranscriptBusySpinnerRenders(t *testing.T) {
12891289 }
12901290
12911291 func TestStatusLabelCoversAll(t *testing.T) {
1292- for _, s := range []swarm.Status{
1293- swarm.StatusPending, swarm.StatusRunning,
1294- swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,
1292+ for _, s := range []subagents.Status{
1293+ subagents.StatusPending, subagents.StatusRunning,
1294+ subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled,
12951295 } {
12961296 if statusLabel(s) == "" {
12971297 t.Fatalf("no label for %s", s)
A packages/agent/modes/subagents_slash.go +468 -0
@@ -0,0 +1,468 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/patriceckhart/zot/packages/agent/subagents"
9+ "github.com/patriceckhart/zot/packages/provider"
10+ "github.com/patriceckhart/zot/packages/tui"
11+)
12+
13+// runSubagents dispatches /subagents subcommands. Layout:
14+//
15+// /subagents -> open the dashboard
16+// /subagents list -> open the dashboard
17+// /subagents new [--agent A] [--model M] [--provider P] [--reasoning L] <task...>
18+// -> spawn an agent with optional profile/model overrides
19+// /subagents kill <id> -> stop a running agent
20+// /subagents cancel <id> -> cancel the active turn
21+// /subagents remove <id> -> tear down a terminated agent
22+// /subagents logs <id> -> open the scrollable transcript view
23+// /subagents result|inspect <id> -> show the completed result
24+// /subagents wait <id> -> wait for the agent to finish
25+// /subagents send <id> <text...> -> send a follow-up user turn to <id>
26+// /subagents resume [id] -> resume an agent (omit id to pick from a list)
27+// /subagents restart-task|restart <id> -> restart the original task
28+// /subagents attach <id> -> (planned) drop into the agent's TUI
29+//
30+// When cfg.Supervisor is nil the command tells the user the feature is
31+// disabled instead of pretending to work.
32+func (i *Interactive) runSubagents(ctx context.Context, args []string) {
33+ if i.cfg.Supervisor == nil {
34+ i.mu.Lock()
35+ i.statusErr = "subagent is disabled in this build"
36+ i.statusOK = ""
37+ i.mu.Unlock()
38+ return
39+ }
40+
41+ sub := ""
42+ rest := ""
43+ if len(args) > 0 {
44+ sub = strings.ToLower(args[0])
45+ // Guard the args[1:] reslice: when only the subcommand was
46+ // typed (e.g. bare "/subagents new"), args has length 1 and the
47+ // naive args[1:] is fine, but when args is empty (bare
48+ // "/subagents") the reslice is [1:0] and panics. The len>0 branch
49+ // here keeps both cases safe.
50+ if len(args) > 1 {
51+ rest = strings.TrimSpace(strings.Join(args[1:], " "))
52+ }
53+ }
54+
55+ // spawnAdapter, sendAdapter, resumeAdapter wrap the Supervisor methods
56+ // in the signatures the dialog expects. Defined once here so the
57+ // three Open()-shaped entry points (list, logs/view-jump, resume)
58+ // feed the dialog identical callbacks.
59+ spawnAdapter := func(task, model, provider string) error {
60+ _, err := i.cfg.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
61+ Task: task, Model: model, Provider: provider, Reasoning: i.cfg.Reasoning,
62+ })
63+ return err
64+ }
65+ sendAdapter := func(id, text string) error {
66+ return i.cfg.Supervisor.SendUserTurn(id, text)
67+ }
68+ resumeAdapter := func(id string) error {
69+ _, err := i.cfg.Supervisor.ResumeSession(ctx, id)
70+ return err
71+ }
72+
73+ // Pin every fresh spawn to whatever the host's /model selection
74+ // is right now. This is captured at /subagents time — if the user
75+ // wants a different model for the next subagent agent, they pick it
76+ // via /model first (globally), or, while inside the spawn
77+ // editor, by typing /model on its own line to pop the picker.
78+ i.subagentsDialog.SetCompactMode(i.compactModeEnabled())
79+ i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
80+ i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
81+ if i.cfg.LoggedInProviders != nil {
82+ i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
83+ }
84+
85+ switch sub {
86+ case "", "list", "ls", "ps":
87+ i.subagentsDialog.Open(
88+ i.cfg.Supervisor.SnapshotAll,
89+ i.cfg.Supervisor.Stop,
90+ i.cfg.Supervisor.Remove,
91+ spawnAdapter,
92+ sendAdapter,
93+ resumeAdapter,
94+ i.cfg.CWD,
95+ )
96+ case "new", "spawn":
97+ if rest == "" {
98+ i.subagentsStatus("", "/subagents new <task>: missing task")
99+ return
100+ }
101+ // Permit profile/model/provider/reasoning flags before the task
102+ // so scripts can pin a child without going through the dialog.
103+ // Anything that isn't a recognised flag terminates parsing and
104+ // the rest becomes the task; only leading flags are consumed.
105+ model, provider, reasoning, subagent, task := parseSpawnFlags(rest)
106+ var profileTools []string
107+ var fastModeOverride *bool
108+ if task == "" {
109+ i.subagentsStatus("", "/subagents new: missing task (after any spawn flags)")
110+ return
111+ }
112+ if subagent != "" {
113+ if i.cfg.ResolveSubagent == nil {
114+ i.subagentsStatus("", "spawn: named subagent profiles are unavailable")
115+ return
116+ }
117+ profile, err := i.cfg.ResolveSubagent(subagent)
118+ if err != nil {
119+ i.subagentsStatus("", "spawn: "+err.Error())
120+ return
121+ }
122+ if profile == nil {
123+ i.subagentsStatus("", "spawn: unknown subagent profile "+subagent)
124+ return
125+ }
126+ subagent = profile.Name
127+ profileTools = append([]string(nil), profile.Tools...)
128+ fastModeOverride = profile.FastMode
129+ profileProvider, profileModel := profile.ModelSelection()
130+ if model == "" {
131+ model = profileModel
132+ }
133+ if provider == "" {
134+ provider = profileProvider
135+ }
136+ if reasoning == "" {
137+ reasoning = profile.Thinking
138+ }
139+ }
140+ if model == "" {
141+ model = i.cfg.Model
142+ }
143+ if provider == "" {
144+ provider = i.cfg.Provider
145+ }
146+ if reasoning == "" {
147+ reasoning = i.cfg.Reasoning
148+ }
149+ if reasoning != "" {
150+ normalizedReasoning, err := normalizeSpawnReasoning(reasoning)
151+ if err != nil {
152+ i.subagentsStatus("", "spawn: "+err.Error())
153+ return
154+ }
155+ reasoning = normalizedReasoning
156+ }
157+ a, err := i.cfg.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
158+ Task: task, Model: model, Provider: provider, Reasoning: reasoning,
159+ FastMode: fastModeOverride, Subagent: subagent,
160+ Tools: profileTools,
161+ })
162+ if err != nil {
163+ i.subagentsStatus("", "spawn: "+err.Error())
164+ return
165+ }
166+ status := "spawned " + a.ID
167+ if subagent != "" {
168+ status += " (agent " + subagent + ")"
169+ } else if model != "" {
170+ status += " (model " + model + ")"
171+ }
172+ i.subagentsStatus(status, "")
173+ case "kill", "stop":
174+ if rest == "" {
175+ i.subagentsStatus("", "/subagents kill <id>: missing id")
176+ return
177+ }
178+ if err := i.cfg.Supervisor.Stop(rest); err != nil {
179+ i.subagentsStatus("", "kill: "+err.Error())
180+ return
181+ }
182+ i.subagentsStatus("stopped "+rest, "")
183+ case "cancel":
184+ if rest == "" {
185+ i.subagentsStatus("", "/subagents cancel <id>: missing id")
186+ return
187+ }
188+ if err := i.cfg.Supervisor.CancelTurn(rest); err != nil {
189+ i.subagentsStatus("", "cancel: "+err.Error())
190+ return
191+ }
192+ i.subagentsStatus("canceling turn "+rest, "")
193+ case "remove", "rm":
194+ if rest == "" {
195+ i.subagentsStatus("", "/subagents remove <id>: missing id")
196+ return
197+ }
198+ if err := i.cfg.Supervisor.Remove(rest); err != nil {
199+ i.subagentsStatus("", "remove: "+err.Error())
200+ return
201+ }
202+ i.subagentsStatus("removed "+rest, "")
203+ case "logs", "log", "view":
204+ if rest == "" {
205+ i.subagentsStatus("", "/subagents logs <id>: missing id")
206+ return
207+ }
208+ ok := i.subagentsDialog.OpenViewing(
209+ rest,
210+ i.cfg.Supervisor.SnapshotAll,
211+ i.cfg.Supervisor.Stop,
212+ i.cfg.Supervisor.Remove,
213+ spawnAdapter,
214+ sendAdapter,
215+ resumeAdapter,
216+ i.cfg.CWD,
217+ )
218+ if !ok {
219+ i.subagentsStatus("", "/subagents logs: no agent matching "+rest)
220+ }
221+ case "result", "inspect":
222+ if rest == "" {
223+ i.subagentsStatus("", "/subagents result <id>: missing id")
224+ return
225+ }
226+ result, err := i.cfg.Supervisor.ReadResult(rest)
227+ if err != nil {
228+ i.subagentsStatus("", "result: "+err.Error())
229+ return
230+ }
231+ status := fmt.Sprintf("%s result %s", result.Status, i.cfg.Supervisor.ResultReference(rest))
232+ if result.Summary != "" {
233+ status += ": " + truncateStatus(result.Summary, 160)
234+ }
235+ i.subagentsStatus(status, "")
236+ case "wait":
237+ if rest == "" {
238+ i.subagentsStatus("", "/subagents wait <id>: missing id")
239+ return
240+ }
241+ a := i.cfg.Supervisor.Get(rest)
242+ if a == nil {
243+ i.subagentsStatus("", "wait: no such agent "+rest)
244+ return
245+ }
246+ waitWatcherDone := i.subagentsWaitWatcherDone
247+ go func() {
248+ defer func() {
249+ if waitWatcherDone != nil {
250+ waitWatcherDone()
251+ }
252+ }()
253+ if err := a.WaitContext(ctx); err != nil {
254+ return
255+ }
256+ // If cancellation became observable at the same time as
257+ // completion, do not report a completion that the caller
258+ // no longer owns.
259+ if ctx.Err() == nil {
260+ i.subagentsStatus("completed "+a.ID, "")
261+ }
262+ }()
263+ i.subagentsStatus("waiting for "+a.ID, "")
264+ case "resume-session", "resume", "reattach", "reopen":
265+ if rest == "" {
266+ count := i.subagentsDialog.OpenForResume(
267+ i.cfg.Supervisor.SnapshotAll,
268+ i.cfg.Supervisor.Stop,
269+ i.cfg.Supervisor.Remove,
270+ spawnAdapter,
271+ sendAdapter,
272+ resumeAdapter,
273+ i.cfg.CWD,
274+ )
275+ switch count {
276+ case 0:
277+ i.subagentsStatus("", "/subagents resume-session: no resumable agents")
278+ case 1:
279+ i.subagentsStatus("1 resumable agent, press R to resume", "")
280+ default:
281+ i.subagentsStatus(fmt.Sprintf("%d resumable agents, ↑/↓ to pick, R to resume", count), "")
282+ }
283+ return
284+ }
285+ a, err := i.cfg.Supervisor.ResumeSession(ctx, rest)
286+ if err != nil {
287+ i.subagentsStatus("", "resume-session: "+err.Error())
288+ return
289+ }
290+ i.subagentsStatus("resumed "+a.ID, "")
291+ case "restart-task", "restart":
292+ if rest == "" {
293+ i.subagentsStatus("", "/subagents restart-task <id>: missing id")
294+ return
295+ }
296+ a, err := i.cfg.Supervisor.RestartTask(ctx, rest)
297+ if err != nil {
298+ i.subagentsStatus("", "restart-task: "+err.Error())
299+ return
300+ }
301+ i.subagentsStatus("restarted task "+a.ID, "")
302+ case "send", "prompt", "msg":
303+ // /subagents send <id> <text...> is the non-interactive
304+ // counterpart of pressing 'p' in the dashboard. We split the
305+ // joined `rest` ourselves rather than reusing the dispatcher's
306+ // already-fielded args[] because the text may contain spaces
307+ // the user expects to be preserved verbatim.
308+ id, text := splitIDAndRest(rest)
309+ if id == "" {
310+ i.subagentsStatus("", "/subagents send <id> <text>: missing id")
311+ return
312+ }
313+ if text == "" {
314+ i.subagentsStatus("", "/subagents send <id> <text>: missing text")
315+ return
316+ }
317+ if err := i.cfg.Supervisor.SendUserTurn(id, text); err != nil {
318+ i.subagentsStatus("", friendlySendErr(id, err))
319+ return
320+ }
321+ i.subagentsStatus("sent to "+id, "")
322+ case "attach":
323+ // PTY-reparenting is a significant chunk of work I haven't
324+ // landed yet (see the design sketch). Recognise the name so
325+ // /subagents attach doesn't fall through to the generic "unknown
326+ // subcommand" path — that error message is misleading because
327+ // it makes attach sound like a typo instead of a planned
328+ // feature. Point the user at /subagents logs in the meantime.
329+ i.subagentsStatus("", "/subagents attach: not implemented yet (needs PTY reparenting). Use /subagents logs "+firstWord(rest)+" to watch its transcript.")
330+ default:
331+ i.subagentsStatus("", "/subagents: unknown subcommand "+sub+" (try list / new / kill / cancel / remove / logs / send / result / wait / resume-session / restart-task)")
332+ }
333+}
334+
335+// normalizeSpawnReasoning validates and canonicalizes the reasoning levels
336+// accepted by ParseArgs before a child process is launched.
337+func normalizeSpawnReasoning(value string) (string, error) {
338+ raw := strings.ToLower(strings.TrimSpace(value))
339+ switch raw {
340+ case "", "off":
341+ return raw, nil
342+ case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max":
343+ return provider.NormalizeReasoning(raw), nil
344+ default:
345+ return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
346+ }
347+}
348+
349+// parseSpawnFlags consumes leading profile/model/provider/reasoning flags
350+// using both separate-value and equals forms. Flags after the task remain
351+// part of the task text.
352+func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {
353+ fields := strings.Fields(s)
354+ i := 0
355+ for i < len(fields) {
356+ f := fields[i]
357+ switch {
358+ case f == "--agent":
359+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
360+ subagent = fields[i+1]
361+ i += 2
362+ } else {
363+ i++
364+ }
365+ continue
366+ case strings.HasPrefix(f, "--agent="):
367+ subagent = strings.TrimPrefix(f, "--agent=")
368+ i++
369+ continue
370+ case f == "--model":
371+ // Consume the flag even when no value follows so a
372+ // dangling "--model" doesn't leak into the task. The
373+ // caller surfaces "missing task" instead.
374+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
375+ model = fields[i+1]
376+ i += 2
377+ } else {
378+ i++
379+ }
380+ continue
381+ case strings.HasPrefix(f, "--model="):
382+ model = strings.TrimPrefix(f, "--model=")
383+ i++
384+ continue
385+ case f == "--provider":
386+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
387+ provider = fields[i+1]
388+ i += 2
389+ } else {
390+ i++
391+ }
392+ continue
393+ case strings.HasPrefix(f, "--provider="):
394+ provider = strings.TrimPrefix(f, "--provider=")
395+ i++
396+ continue
397+ case f == "--reasoning" || f == "--thinking":
398+ if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
399+ reasoning = fields[i+1]
400+ i += 2
401+ } else {
402+ i++
403+ }
404+ continue
405+ case strings.HasPrefix(f, "--reasoning="):
406+ reasoning = strings.TrimPrefix(f, "--reasoning=")
407+ i++
408+ continue
409+ case strings.HasPrefix(f, "--thinking="):
410+ reasoning = strings.TrimPrefix(f, "--thinking=")
411+ i++
412+ continue
413+ }
414+ break
415+ }
416+ task = strings.TrimSpace(strings.Join(fields[i:], " "))
417+ return
418+}
419+
420+// splitIDAndRest splits "<id> <text...>" into (id, text). The text
421+// half preserves all whitespace after the first token so the agent
422+// receives the user's prompt verbatim (modulo a single trim of the
423+// boundary space). Returns ("", "") when s is empty so the caller
424+// can surface a missing-id error.
425+func splitIDAndRest(s string) (id, text string) {
426+ s = strings.TrimLeft(s, " \t")
427+ if s == "" {
428+ return "", ""
429+ }
430+ cut := strings.IndexAny(s, " \t")
431+ if cut < 0 {
432+ return s, ""
433+ }
434+ return s[:cut], strings.TrimLeft(s[cut+1:], " \t")
435+}
436+
437+// firstWord returns the first whitespace-separated token of s, or
438+// "<id>" when s is empty. Used to keep the "/subagents attach" hint
439+// readable even when the user typed no argument.
440+func firstWord(s string) string {
441+ s = strings.TrimSpace(s)
442+ if s == "" {
443+ return "<id>"
444+ }
445+ if i := strings.IndexAny(s, " \t"); i >= 0 {
446+ return s[:i]
447+ }
448+ return s
449+}
450+
451+func truncateStatus(value string, max int) string {
452+ value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ")
453+ if len([]rune(value)) <= max {
454+ return value
455+ }
456+ if max <= 3 {
457+ return string([]rune(value)[:max])
458+ }
459+ return string([]rune(value)[:max-3]) + "..."
460+}
461+
462+func (i *Interactive) subagentsStatus(ok, errMsg string) {
463+ i.mu.Lock()
464+ i.statusOK = ok
465+ i.statusErr = errMsg
466+ i.mu.Unlock()
467+ i.invalidate()
468+}
A packages/agent/modes/subagents_slash_test.go +401 -0
@@ -0,0 +1,401 @@
1+package modes
2+
3+import (
4+ "context"
5+ "runtime"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/packages/agent/subagents"
11+)
12+
13+// newInteractiveForSupervisorTest builds the minimal Interactive scaffolding
14+// runSubagents needs. It does NOT call NewInteractive (which would pull in
15+// the whole TUI); the runSubagents method only touches cfg.Supervisor, the
16+// status mutex, and the subagent dialog, so we hand-build those.
17+func newInteractiveForSupervisorTest(t *testing.T) (*Interactive, *subagents.Supervisor) {
18+ t.Helper()
19+ root := t.TempDir()
20+ f := subagents.New(subagents.Config{
21+ Root: root,
22+ RepoRoot: root,
23+ NewRunner: func(a *subagents.Agent) subagents.Runner {
24+ return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
25+ <-ctx.Done()
26+ return ctx.Err()
27+ })
28+ },
29+ })
30+ iv := &Interactive{
31+ subagentsDialog: newSubagentsDialog(),
32+ dirty: make(chan struct{}, 1),
33+ }
34+ iv.cfg.Supervisor = f
35+ return iv, f
36+}
37+
38+// TestRunSupervisorBareDoesNotPanic regression-tests the slice-out-of-range
39+// panic that hit when /subagents was typed with no subcommand: runSubagents
40+// did args[1:] without checking len(args), which panics as [1:0].
41+func TestRunSupervisorBareDoesNotPanic(t *testing.T) {
42+ iv, _ := newInteractiveForSupervisorTest(t)
43+ defer iv.cfg.Supervisor.StopAll()
44+
45+ // Bare /subagents: parts[1:] from the dispatcher is an empty slice.
46+ iv.runSubagents(context.Background(), nil)
47+
48+ if !iv.subagentsDialog.Active() {
49+ t.Fatal("bare /subagents should open the dashboard")
50+ }
51+}
52+
53+func TestRunSupervisorSubcommandsDoNotPanic(t *testing.T) {
54+ iv, _ := newInteractiveForSupervisorTest(t)
55+ defer iv.cfg.Supervisor.StopAll()
56+
57+ // Each row is the slice that the dispatcher hands to runSubagents —
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("runSubagents(%v) panicked: %v", args, r)
79+ }
80+ }()
81+ iv.runSubagents(context.Background(), args)
82+ }()
83+ }
84+ iv.mu.Lock()
85+ statusErr := iv.statusErr
86+ iv.mu.Unlock()
87+ if !strings.Contains(statusErr, "cancel") || !strings.Contains(statusErr, "wait") {
88+ t.Fatalf("unknown-command hint = %q; want cancel and wait", statusErr)
89+ }
90+}
91+
92+func TestRunSupervisorRejectsNamedProfileWithoutResolver(t *testing.T) {
93+ iv, f := newInteractiveForSupervisorTest(t)
94+ defer f.StopAll()
95+
96+ iv.runSubagents(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"})
97+ if !strings.Contains(iv.statusErr, "named subagent profiles are unavailable") {
98+ t.Fatalf("status error = %q, want unavailable profile resolver", iv.statusErr)
99+ }
100+ if got := len(f.List()); got != 0 {
101+ t.Fatalf("spawned agents = %d, want 0", got)
102+ }
103+}
104+
105+func TestRunSupervisorNewSpawnsAgent(t *testing.T) {
106+ iv, f := newInteractiveForSupervisorTest(t)
107+ defer f.StopAll()
108+
109+ iv.runSubagents(context.Background(), []string{"new", "do", "stuff"})
110+ agents := f.List()
111+ if len(agents) != 1 {
112+ t.Fatalf("want 1 agent; got %d", len(agents))
113+ }
114+ if agents[0].Task != "do stuff" {
115+ t.Fatalf("task = %q; want %q", agents[0].Task, "do stuff")
116+ }
117+}
118+
119+func TestRunSupervisorNamedProfileAppliesFastModeRestriction(t *testing.T) {
120+ root := t.TempDir()
121+ profileFastMode := false
122+ f := subagents.New(subagents.Config{
123+ Root: root,
124+ RepoRoot: root,
125+ FastMode: true,
126+ NewRunner: func(*subagents.Agent) subagents.Runner {
127+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
128+ <-ctx.Done()
129+ return ctx.Err()
130+ })
131+ },
132+ })
133+ defer f.StopAll()
134+ iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)}
135+ iv.cfg.Supervisor = f
136+ iv.cfg.ResolveSubagent = func(name string) (*subagents.Profile, error) {
137+ if name != "reviewer" {
138+ return nil, nil
139+ }
140+ return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
141+ }
142+
143+ iv.runSubagents(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"})
144+ agents := f.List()
145+ if len(agents) != 1 || agents[0].FastMode {
146+ t.Fatalf("agent fast mode = %#v, want disabled by profile", agents)
147+ }
148+}
149+
150+// TestRunSupervisorSendDeliversToAgentInbox spins up a real agent with a
151+// fake Runner whose only job is to forward inbox lines to a channel,
152+// then asserts the /subagents send <id> <text...> path routes through
153+// Supervisor.SendUserTurn and lands at the agent verbatim.
154+func TestRunSupervisorSendDeliversToAgentInbox(t *testing.T) {
155+ if runtime.GOOS == "windows" {
156+ t.Skip("subagent inbox transport uses Unix-domain sockets")
157+ }
158+ root := t.TempDir()
159+ recv := make(chan string, 4)
160+ ready := make(chan error, 1)
161+ f := subagents.New(subagents.Config{
162+ Root: root,
163+ RepoRoot: root,
164+ NewRunner: func(a *subagents.Agent) subagents.Runner {
165+ return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
166+ // Stand up a real Listener on the agent's inbox path so
167+ // SendUserTurn (which dials a unix socket) actually has
168+ // something to talk to. The runner-test stubs do the
169+ // same; this is the minimum to exercise the wire.
170+ ln, err := subagents.Listen(a.InboxPath)
171+ ready <- err
172+ if err != nil {
173+ return err
174+ }
175+ defer ln.Close()
176+ for {
177+ select {
178+ case <-ctx.Done():
179+ return ctx.Err()
180+ case line, ok := <-ln.Lines():
181+ if !ok {
182+ return nil
183+ }
184+ recv <- line
185+ }
186+ }
187+ })
188+ },
189+ })
190+ defer f.StopAll()
191+ iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)}
192+ iv.cfg.Supervisor = f
193+
194+ a, err := f.Spawn(context.Background(), "do thing")
195+ if err != nil {
196+ t.Fatalf("spawn: %v", err)
197+ }
198+ select {
199+ case err := <-ready:
200+ if err != nil {
201+ t.Fatalf("listen: %v", err)
202+ }
203+ case <-time.After(5 * time.Second):
204+ t.Fatal("timed out waiting for agent inbox listener")
205+ }
206+
207+ // Run /subagents send <id> <text...>. The dispatcher would have
208+ // already strings.Fields-ed the input; mirror that here.
209+ iv.runSubagents(context.Background(), []string{"send", a.ID, "please", "continue"})
210+
211+ select {
212+ case msg := <-recv:
213+ command, parseErr := subagents.ParseCommand(msg)
214+ if parseErr != nil {
215+ t.Fatalf("agent received invalid command %q: %v", msg, parseErr)
216+ }
217+ if command.Type != subagents.CommandTurnStart {
218+ t.Fatalf("agent received command %q; want %q", command.Type, subagents.CommandTurnStart)
219+ }
220+ var payload subagents.TurnStartPayload
221+ if err := command.DecodePayload(&payload); err != nil {
222+ t.Fatalf("decode turn.start payload: %v", err)
223+ }
224+ if payload.Prompt != "please continue" {
225+ t.Fatalf("agent received prompt %q; want %q", payload.Prompt, "please continue")
226+ }
227+ case <-time.After(2 * time.Second):
228+ t.Fatal("timed out waiting for agent to receive the prompt")
229+ }
230+
231+ if iv.statusErr != "" {
232+ t.Fatalf("status err set: %q", iv.statusErr)
233+ }
234+ if iv.statusOK == "" || iv.statusOK[:7] != "sent to" {
235+ t.Fatalf("status ok = %q; want \"sent to ...\"", iv.statusOK)
236+ }
237+}
238+
239+func TestRunSupervisorWaitHonorsRunContext(t *testing.T) {
240+ iv, f := newInteractiveForSupervisorTest(t)
241+ defer f.StopAll()
242+
243+ a, err := f.Spawn(context.Background(), "long-running task")
244+ if err != nil {
245+ t.Fatal(err)
246+ }
247+ ctx, cancel := context.WithCancel(context.Background())
248+ watcherDone := make(chan struct{})
249+ iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
250+ iv.runSubagents(ctx, []string{"wait", a.ID})
251+ iv.mu.Lock()
252+ status := iv.statusOK
253+ iv.mu.Unlock()
254+ if status != "waiting for "+a.ID {
255+ t.Fatalf("initial wait status = %q", status)
256+ }
257+ cancel()
258+
259+ // The wait watcher must stop observing the run context and must not
260+ // later overwrite the status with a false completion after cleanup.
261+ deadline := time.NewTimer(time.Second)
262+ defer deadline.Stop()
263+ select {
264+ case <-watcherDone:
265+ case <-deadline.C:
266+ t.Fatal("timed out waiting for wait watcher to exit")
267+ }
268+ iv.mu.Lock()
269+ status = iv.statusOK
270+ iv.mu.Unlock()
271+ if strings.Contains(status, "completed ") {
272+ t.Fatalf("wait reported completion after cancellation: %q", status)
273+ }
274+}
275+
276+func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) {
277+ root := t.TempDir()
278+ f := subagents.New(subagents.Config{
279+ Root: root,
280+ RepoRoot: root,
281+ NewRunner: func(*subagents.Agent) subagents.Runner {
282+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
283+ },
284+ })
285+ defer f.StopAll()
286+ iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)}
287+ iv.cfg.Supervisor = f
288+
289+ a, err := f.Spawn(context.Background(), "quick task")
290+ if err != nil {
291+ t.Fatal(err)
292+ }
293+ iv.runSubagents(context.Background(), []string{"wait", a.ID})
294+ deadline := time.After(time.Second)
295+ for {
296+ iv.mu.Lock()
297+ status := iv.statusOK
298+ iv.mu.Unlock()
299+ if status == "completed "+a.ID {
300+ return
301+ }
302+ select {
303+ case <-deadline:
304+ t.Fatalf("wait did not report completion; status = %q", status)
305+ case <-time.After(time.Millisecond):
306+ }
307+ }
308+}
309+
310+func TestNormalizeSpawnReasoning(t *testing.T) {
311+ cases := []struct {
312+ in, want string
313+ ok bool
314+ }{
315+ {in: "OFF", want: "off", ok: true},
316+ {in: "minimal", want: "minimum", ok: true},
317+ {in: "maximum", want: "xhigh", ok: true},
318+ {in: "MAX", want: "max", ok: true},
319+ {in: "bogus", ok: false},
320+ }
321+ for _, tc := range cases {
322+ got, err := normalizeSpawnReasoning(tc.in)
323+ if tc.ok {
324+ if err != nil || got != tc.want {
325+ t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
326+ }
327+ continue
328+ }
329+ if err == nil {
330+ t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got)
331+ }
332+ }
333+}
334+
335+func TestParseSpawnFlags(t *testing.T) {
336+ cases := []struct {
337+ in string
338+ wantModel, wantProv string
339+ wantReasoning, wantSubagent string
340+ wantTask string
341+ }{
342+ {"do x", "", "", "", "", "do x"},
343+ {"--model claude do x", "claude", "", "", "", "do x"},
344+ {"--model=claude do x", "claude", "", "", "", "do x"},
345+ {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
346+ {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
347+ {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"},
348+ {"--agent --reasoning high review auth", "", "", "high", "", "review auth"},
349+ {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"},
350+ {"--thinking=max do x", "", "", "max", "", "do x"},
351+ // Only LEADING flags are consumed.
352+ {"do --model x", "", "", "", "", "do --model x"},
353+ // Missing value: --model with no follow-up token leaves model empty
354+ // and the next field starts the task.
355+ {"--model", "", "", "", "", ""},
356+ }
357+ for _, c := range cases {
358+ m, p, r, a, task := parseSpawnFlags(c.in)
359+ if m != c.wantModel || p != c.wantProv || r != c.wantReasoning || a != c.wantSubagent || task != c.wantTask {
360+ t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q,%q,%q); want (%q,%q,%q,%q,%q)",
361+ c.in, m, p, r, a, task, c.wantModel, c.wantProv, c.wantReasoning, c.wantSubagent, c.wantTask)
362+ }
363+ }
364+}
365+
366+func TestSplitIDAndRest(t *testing.T) {
367+ cases := []struct {
368+ in string
369+ wantID string
370+ wantText string
371+ }{
372+ {"", "", ""},
373+ {" ", "", ""},
374+ {"alpha", "alpha", ""},
375+ {"alpha hello world", "alpha", "hello world"},
376+ {" alpha hello world ", "alpha", "hello world "},
377+ {"alpha\thi", "alpha", "hi"},
378+ }
379+ for _, c := range cases {
380+ gotID, gotText := splitIDAndRest(c.in)
381+ if gotID != c.wantID || gotText != c.wantText {
382+ t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText)
383+ }
384+ }
385+}
386+
387+func TestRunSupervisorWithoutSupervisorIsNoop(t *testing.T) {
388+ iv := &Interactive{
389+ subagentsDialog: newSubagentsDialog(),
390+ dirty: make(chan struct{}, 1),
391+ }
392+ // cfg.Supervisor stays nil. The command should set a status err and
393+ // otherwise be inert.
394+ iv.runSubagents(context.Background(), nil)
395+ if iv.subagentsDialog.Active() {
396+ t.Fatal("dialog opened despite no subagent")
397+ }
398+ if iv.statusErr == "" {
399+ t.Fatal("expected a status error when subagent is nil")
400+ }
401+}
D packages/agent/modes/swarm_slash.go +0 -384
@@ -1,384 +0,0 @@
1-package modes
2-
3-import (
4- "context"
5- "fmt"
6- "strings"
7-
8- "github.com/patriceckhart/zot/packages/agent/swarm"
9- "github.com/patriceckhart/zot/packages/provider"
10- "github.com/patriceckhart/zot/packages/tui"
11-)
12-
13-// runSwarm dispatches /swarm subcommands. Layout:
14-//
15-// /swarm -> open the dashboard
16-// /swarm list -> open the dashboard
17-// /swarm new [--agent A] [--model M] [--provider P] [--reasoning L] <task...>
18-// -> spawn an agent with optional profile/model overrides
19-// /swarm kill <id> -> stop a running agent
20-// /swarm remove <id> -> tear down a terminated agent
21-// /swarm logs <id> -> open the scrollable transcript view
22-// /swarm send <id> <text...> -> send a follow-up user turn to <id>
23-// /swarm resume [id] -> resume an agent (omit id to pick from a list)
24-// /swarm attach <id> -> (planned) drop into the agent's TUI
25-//
26-// When cfg.Swarm is nil the command tells the user the feature is
27-// disabled instead of pretending to work.
28-func (i *Interactive) runSwarm(ctx context.Context, args []string) {
29- if i.cfg.Swarm == nil {
30- i.mu.Lock()
31- i.statusErr = "swarm is disabled in this build"
32- i.statusOK = ""
33- i.mu.Unlock()
34- return
35- }
36-
37- sub := ""
38- rest := ""
39- if len(args) > 0 {
40- sub = strings.ToLower(args[0])
41- // Guard the args[1:] reslice: when only the subcommand was
42- // typed (e.g. bare "/swarm new"), args has length 1 and the
43- // naive args[1:] is fine, but when args is empty (bare
44- // "/swarm") the reslice is [1:0] and panics. The len>0 branch
45- // here keeps both cases safe.
46- if len(args) > 1 {
47- rest = strings.TrimSpace(strings.Join(args[1:], " "))
48- }
49- }
50-
51- // spawnAdapter, sendAdapter, resumeAdapter wrap the Swarm methods
52- // in the signatures the dialog expects. Defined once here so the
53- // three Open()-shaped entry points (list, logs/view-jump, resume)
54- // feed the dialog identical callbacks.
55- spawnAdapter := func(task, model, provider string) error {
56- _, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
57- Task: task, Model: model, Provider: provider, Reasoning: i.cfg.Reasoning,
58- })
59- return err
60- }
61- sendAdapter := func(id, text string) error {
62- return i.cfg.Swarm.SendUserTurn(id, text)
63- }
64- resumeAdapter := func(id string) error {
65- _, err := i.cfg.Swarm.Resume(ctx, id)
66- return err
67- }
68-
69- // Pin every fresh spawn to whatever the host's /model selection
70- // is right now. This is captured at /swarm time — if the user
71- // wants a different model for the next swarm agent, they pick it
72- // via /model first (globally), or, while inside the spawn
73- // editor, by typing /model on its own line to pop the picker.
74- i.swarmDialog.SetCompactMode(i.compactModeEnabled())
75- i.swarmDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
76- i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
77- if i.cfg.LoggedInProviders != nil {
78- i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
79- }
80-
81- switch sub {
82- case "", "list", "ls", "ps":
83- i.swarmDialog.Open(
84- i.cfg.Swarm.SnapshotAll,
85- i.cfg.Swarm.Stop,
86- i.cfg.Swarm.Remove,
87- spawnAdapter,
88- sendAdapter,
89- resumeAdapter,
90- i.cfg.CWD,
91- )
92- case "new", "spawn":
93- if rest == "" {
94- i.swarmStatus("", "/swarm new <task>: missing task")
95- return
96- }
97- // Permit profile/model/provider/reasoning flags before the task
98- // so scripts can pin a child without going through the dialog.
99- // Anything that isn't a recognised flag terminates parsing and
100- // the rest becomes the task; only leading flags are consumed.
101- model, provider, reasoning, subagent, task := parseSpawnFlags(rest)
102- var fastModeOverride *bool
103- if task == "" {
104- i.swarmStatus("", "/swarm new: missing task (after any spawn flags)")
105- return
106- }
107- if subagent != "" {
108- if i.cfg.ResolveSubagent == nil {
109- i.swarmStatus("", "spawn: named subagent profiles are unavailable")
110- return
111- }
112- profile, err := i.cfg.ResolveSubagent(subagent)
113- if err != nil {
114- i.swarmStatus("", "spawn: "+err.Error())
115- return
116- }
117- if profile == nil {
118- i.swarmStatus("", "spawn: unknown subagent profile "+subagent)
119- return
120- }
121- subagent = profile.Name
122- fastModeOverride = profile.FastMode
123- if model == "" && provider == "" {
124- profileProvider, profileModel := profile.ModelSelection()
125- if profileProvider != "" && profileModel != "" {
126- provider, model = profileProvider, profileModel
127- }
128- }
129- if reasoning == "" {
130- reasoning = profile.Thinking
131- }
132- }
133- if reasoning == "" {
134- reasoning = i.cfg.Reasoning
135- }
136- if reasoning != "" {
137- normalizedReasoning, err := normalizeSpawnReasoning(reasoning)
138- if err != nil {
139- i.swarmStatus("", "spawn: "+err.Error())
140- return
141- }
142- reasoning = normalizedReasoning
143- }
144- a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
145- Task: task, Model: model, Provider: provider, Reasoning: reasoning,
146- FastMode: fastModeOverride, Subagent: subagent,
147- })
148- if err != nil {
149- i.swarmStatus("", "spawn: "+err.Error())
150- return
151- }
152- status := "spawned " + a.ID
153- if subagent != "" {
154- status += " (agent " + subagent + ")"
155- } else if model != "" {
156- status += " (model " + model + ")"
157- }
158- i.swarmStatus(status, "")
159- case "kill", "stop":
160- if rest == "" {
161- i.swarmStatus("", "/swarm kill <id>: missing id")
162- return
163- }
164- if err := i.cfg.Swarm.Stop(rest); err != nil {
165- i.swarmStatus("", "kill: "+err.Error())
166- return
167- }
168- i.swarmStatus("stopped "+rest, "")
169- case "remove", "rm":
170- if rest == "" {
171- i.swarmStatus("", "/swarm remove <id>: missing id")
172- return
173- }
174- if err := i.cfg.Swarm.Remove(rest); err != nil {
175- i.swarmStatus("", "remove: "+err.Error())
176- return
177- }
178- i.swarmStatus("removed "+rest, "")
179- case "logs", "log", "view":
180- if rest == "" {
181- i.swarmStatus("", "/swarm logs <id>: missing id")
182- return
183- }
184- ok := i.swarmDialog.OpenViewing(
185- rest,
186- i.cfg.Swarm.SnapshotAll,
187- i.cfg.Swarm.Stop,
188- i.cfg.Swarm.Remove,
189- spawnAdapter,
190- sendAdapter,
191- resumeAdapter,
192- i.cfg.CWD,
193- )
194- if !ok {
195- i.swarmStatus("", "/swarm logs: no agent matching "+rest)
196- }
197- case "resume", "reattach", "reopen":
198- if rest == "" {
199- // No id given: open the dashboard with the cursor
200- // pre-positioned on the first resumable agent, and
201- // tell the user how many there are so they know what
202- // to expect. Pressing R confirms; ↑/↓ to pick a
203- // different row first.
204- count := i.swarmDialog.OpenForResume(
205- i.cfg.Swarm.SnapshotAll,
206- i.cfg.Swarm.Stop,
207- i.cfg.Swarm.Remove,
208- spawnAdapter,
209- sendAdapter,
210- resumeAdapter,
211- i.cfg.CWD,
212- )
213- switch count {
214- case 0:
215- i.swarmStatus("", "/swarm resume: no resumable agents (none detached or terminated)")
216- case 1:
217- i.swarmStatus("1 resumable agent, press R to resume", "")
218- default:
219- i.swarmStatus(fmt.Sprintf("%d resumable agents, ↑/↓ to pick, R to resume", count), "")
220- }
221- return
222- }
223- a, err := i.cfg.Swarm.Resume(ctx, rest)
224- if err != nil {
225- i.swarmStatus("", "resume: "+err.Error())
226- return
227- }
228- i.swarmStatus("resumed "+a.ID, "")
229- case "send", "prompt", "msg":
230- // /swarm send <id> <text...> is the non-interactive
231- // counterpart of pressing 'p' in the dashboard. We split the
232- // joined `rest` ourselves rather than reusing the dispatcher's
233- // already-fielded args[] because the text may contain spaces
234- // the user expects to be preserved verbatim.
235- id, text := splitIDAndRest(rest)
236- if id == "" {
237- i.swarmStatus("", "/swarm send <id> <text>: missing id")
238- return
239- }
240- if text == "" {
241- i.swarmStatus("", "/swarm send <id> <text>: missing text")
242- return
243- }
244- if err := i.cfg.Swarm.SendUserTurn(id, text); err != nil {
245- i.swarmStatus("", friendlySendErr(id, err))
246- return
247- }
248- i.swarmStatus("sent to "+id, "")
249- case "attach":
250- // PTY-reparenting is a significant chunk of work I haven't
251- // landed yet (see the design sketch). Recognise the name so
252- // /swarm attach doesn't fall through to the generic "unknown
253- // subcommand" path — that error message is misleading because
254- // it makes attach sound like a typo instead of a planned
255- // feature. Point the user at /swarm logs in the meantime.
256- i.swarmStatus("", "/swarm attach: not implemented yet (needs PTY reparenting). Use /swarm logs "+firstWord(rest)+" to watch its transcript.")
257- default:
258- i.swarmStatus("", "/swarm: unknown subcommand "+sub+" (try list / new / kill / remove / logs / send / resume)")
259- }
260-}
261-
262-// normalizeSpawnReasoning validates and canonicalizes the reasoning levels
263-// accepted by ParseArgs before a child process is launched.
264-func normalizeSpawnReasoning(value string) (string, error) {
265- raw := strings.ToLower(strings.TrimSpace(value))
266- switch raw {
267- case "", "off":
268- return raw, nil
269- case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max":
270- return provider.NormalizeReasoning(raw), nil
271- default:
272- return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
273- }
274-}
275-
276-// parseSpawnFlags consumes leading profile/model/provider/reasoning flags
277-// using both separate-value and equals forms. Flags after the task remain
278-// part of the task text.
279-func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {
280- fields := strings.Fields(s)
281- i := 0
282- for i < len(fields) {
283- f := fields[i]
284- switch {
285- case f == "--agent":
286- if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
287- subagent = fields[i+1]
288- i += 2
289- } else {
290- i++
291- }
292- continue
293- case strings.HasPrefix(f, "--agent="):
294- subagent = strings.TrimPrefix(f, "--agent=")
295- i++
296- continue
297- case f == "--model":
298- // Consume the flag even when no value follows so a
299- // dangling "--model" doesn't leak into the task. The
300- // caller surfaces "missing task" instead.
301- if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
302- model = fields[i+1]
303- i += 2
304- } else {
305- i++
306- }
307- continue
308- case strings.HasPrefix(f, "--model="):
309- model = strings.TrimPrefix(f, "--model=")
310- i++
311- continue
312- case f == "--provider":
313- if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
314- provider = fields[i+1]
315- i += 2
316- } else {
317- i++
318- }
319- continue
320- case strings.HasPrefix(f, "--provider="):
321- provider = strings.TrimPrefix(f, "--provider=")
322- i++
323- continue
324- case f == "--reasoning" || f == "--thinking":
325- if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
326- reasoning = fields[i+1]
327- i += 2
328- } else {
329- i++
330- }
331- continue
332- case strings.HasPrefix(f, "--reasoning="):
333- reasoning = strings.TrimPrefix(f, "--reasoning=")
334- i++
335- continue
336- case strings.HasPrefix(f, "--thinking="):
337- reasoning = strings.TrimPrefix(f, "--thinking=")
338- i++
339- continue
340- }
341- break
342- }
343- task = strings.TrimSpace(strings.Join(fields[i:], " "))
344- return
345-}
346-
347-// splitIDAndRest splits "<id> <text...>" into (id, text). The text
348-// half preserves all whitespace after the first token so the agent
349-// receives the user's prompt verbatim (modulo a single trim of the
350-// boundary space). Returns ("", "") when s is empty so the caller
351-// can surface a missing-id error.
352-func splitIDAndRest(s string) (id, text string) {
353- s = strings.TrimLeft(s, " \t")
354- if s == "" {
355- return "", ""
356- }
357- cut := strings.IndexAny(s, " \t")
358- if cut < 0 {
359- return s, ""
360- }
361- return s[:cut], strings.TrimLeft(s[cut+1:], " \t")
362-}
363-
364-// firstWord returns the first whitespace-separated token of s, or
365-// "<id>" when s is empty. Used to keep the "/swarm attach" hint
366-// readable even when the user typed no argument.
367-func firstWord(s string) string {
368- s = strings.TrimSpace(s)
369- if s == "" {
370- return "<id>"
371- }
372- if i := strings.IndexAny(s, " \t"); i >= 0 {
373- return s[:i]
374- }
375- return s
376-}
377-
378-func (i *Interactive) swarmStatus(ok, errMsg string) {
379- i.mu.Lock()
380- i.statusOK = ok
381- i.statusErr = errMsg
382- i.mu.Unlock()
383- i.invalidate()
384-}
D packages/agent/modes/swarm_slash_test.go +0 -315
@@ -1,315 +0,0 @@
1-package modes
2-
3-import (
4- "context"
5- "runtime"
6- "strings"
7- "testing"
8- "time"
9-
10- "github.com/patriceckhart/zot/packages/agent/subagents"
11- "github.com/patriceckhart/zot/packages/agent/swarm"
12-)
13-
14-// newInteractiveForSwarmTest builds the minimal Interactive scaffolding
15-// runSwarm needs. It does NOT call NewInteractive (which would pull in
16-// the whole TUI); the runSwarm method only touches cfg.Swarm, the
17-// status mutex, and the swarm dialog, so we hand-build those.
18-func newInteractiveForSwarmTest(t *testing.T) (*Interactive, *swarm.Swarm) {
19- t.Helper()
20- root := t.TempDir()
21- f := swarm.New(swarm.Config{
22- Root: root,
23- RepoRoot: root,
24- NewRunner: func(a *swarm.Agent) swarm.Runner {
25- return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {
26- <-ctx.Done()
27- return ctx.Err()
28- })
29- },
30- })
31- iv := &Interactive{
32- swarmDialog: newSwarmDialog(),
33- dirty: make(chan struct{}, 1),
34- }
35- iv.cfg.Swarm = f
36- return iv, f
37-}
38-
39-// TestRunSwarmBareDoesNotPanic regression-tests the slice-out-of-range
40-// panic that hit when /swarm was typed with no subcommand: runSwarm
41-// did args[1:] without checking len(args), which panics as [1:0].
42-func TestRunSwarmBareDoesNotPanic(t *testing.T) {
43- iv, _ := newInteractiveForSwarmTest(t)
44- defer iv.cfg.Swarm.StopAll()
45-
46- // Bare /swarm: parts[1:] from the dispatcher is an empty slice.
47- iv.runSwarm(context.Background(), nil)
48-
49- if !iv.swarmDialog.Active() {
50- t.Fatal("bare /swarm should open the dashboard")
51- }
52-}
53-
54-func TestRunSwarmSubcommandsDoNotPanic(t *testing.T) {
55- iv, _ := newInteractiveForSwarmTest(t)
56- defer iv.cfg.Swarm.StopAll()
57-
58- // Each row is the slice that the dispatcher hands to runSwarm —
59- // i.e. parts[1:] where parts was strings.Fields of the slash
60- // command. Mixing zero-arg and arg'd forms exercises both
61- // branches of the reslice guard.
62- cases := [][]string{
63- {"list"},
64- {"new"},
65- {"new", "fix", "the", "thing"},
66- {"kill"},
67- {"kill", "no-such-id"},
68- {"remove"},
69- {"remove", "no-such-id"},
70- {"send"},
71- {"send", "no-such-id"},
72- {"send", "no-such-id", "hello", "world"},
73- {"bogus"},
74- }
75- for _, args := range cases {
76- func() {
77- defer func() {
78- if r := recover(); r != nil {
79- t.Fatalf("runSwarm(%v) panicked: %v", args, r)
80- }
81- }()
82- iv.runSwarm(context.Background(), args)
83- }()
84- }
85-}
86-
87-func TestRunSwarmRejectsNamedProfileWithoutResolver(t *testing.T) {
88- iv, f := newInteractiveForSwarmTest(t)
89- defer f.StopAll()
90-
91- iv.runSwarm(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"})
92- if !strings.Contains(iv.statusErr, "named subagent profiles are unavailable") {
93- t.Fatalf("status error = %q, want unavailable profile resolver", iv.statusErr)
94- }
95- if got := len(f.List()); got != 0 {
96- t.Fatalf("spawned agents = %d, want 0", got)
97- }
98-}
99-
100-func TestRunSwarmNewSpawnsAgent(t *testing.T) {
101- iv, f := newInteractiveForSwarmTest(t)
102- defer f.StopAll()
103-
104- iv.runSwarm(context.Background(), []string{"new", "do", "stuff"})
105- agents := f.List()
106- if len(agents) != 1 {
107- t.Fatalf("want 1 agent; got %d", len(agents))
108- }
109- if agents[0].Task != "do stuff" {
110- t.Fatalf("task = %q; want %q", agents[0].Task, "do stuff")
111- }
112-}
113-
114-func TestRunSwarmNamedProfileAppliesFastModeRestriction(t *testing.T) {
115- root := t.TempDir()
116- profileFastMode := false
117- f := swarm.New(swarm.Config{
118- Root: root,
119- RepoRoot: root,
120- FastMode: true,
121- NewRunner: func(*swarm.Agent) swarm.Runner {
122- return swarm.RunnerFunc(func(ctx context.Context, _ swarm.Sink) error {
123- <-ctx.Done()
124- return ctx.Err()
125- })
126- },
127- })
128- defer f.StopAll()
129- iv := &Interactive{swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1)}
130- iv.cfg.Swarm = f
131- iv.cfg.ResolveSubagent = func(name string) (*subagents.Profile, error) {
132- if name != "reviewer" {
133- return nil, nil
134- }
135- return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
136- }
137-
138- iv.runSwarm(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"})
139- agents := f.List()
140- if len(agents) != 1 || agents[0].FastMode {
141- t.Fatalf("agent fast mode = %#v, want disabled by profile", agents)
142- }
143-}
144-
145-// TestRunSwarmSendDeliversToAgentInbox spins up a real agent with a
146-// fake Runner whose only job is to forward inbox lines to a channel,
147-// then asserts the /swarm send <id> <text...> path routes through
148-// Swarm.SendUserTurn and lands at the agent verbatim.
149-func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) {
150- if runtime.GOOS == "windows" {
151- t.Skip("swarm inbox transport uses Unix-domain sockets")
152- }
153- root := t.TempDir()
154- recv := make(chan string, 4)
155- ready := make(chan error, 1)
156- f := swarm.New(swarm.Config{
157- Root: root,
158- RepoRoot: root,
159- NewRunner: func(a *swarm.Agent) swarm.Runner {
160- return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {
161- // Stand up a real Listener on the agent's inbox path so
162- // SendUserTurn (which dials a unix socket) actually has
163- // something to talk to. The runner-test stubs do the
164- // same; this is the minimum to exercise the wire.
165- ln, err := swarm.Listen(a.InboxPath)
166- ready <- err
167- if err != nil {
168- return err
169- }
170- defer ln.Close()
171- for {
172- select {
173- case <-ctx.Done():
174- return ctx.Err()
175- case line, ok := <-ln.Lines():
176- if !ok {
177- return nil
178- }
179- recv <- line
180- }
181- }
182- })
183- },
184- })
185- defer f.StopAll()
186- iv := &Interactive{swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1)}
187- iv.cfg.Swarm = f
188-
189- a, err := f.Spawn(context.Background(), "do thing")
190- if err != nil {
191- t.Fatalf("spawn: %v", err)
192- }
193- select {
194- case err := <-ready:
195- if err != nil {
196- t.Fatalf("listen: %v", err)
197- }
198- case <-time.After(5 * time.Second):
199- t.Fatal("timed out waiting for agent inbox listener")
200- }
201-
202- // Run /swarm send <id> <text...>. The dispatcher would have
203- // already strings.Fields-ed the input; mirror that here.
204- iv.runSwarm(context.Background(), []string{"send", a.ID, "please", "continue"})
205-
206- select {
207- case msg := <-recv:
208- want := "user please continue"
209- if msg != want {
210- t.Fatalf("agent received %q; want %q", msg, want)
211- }
212- case <-time.After(2 * time.Second):
213- t.Fatal("timed out waiting for agent to receive the prompt")
214- }
215-
216- if iv.statusErr != "" {
217- t.Fatalf("status err set: %q", iv.statusErr)
218- }
219- if iv.statusOK == "" || iv.statusOK[:7] != "sent to" {
220- t.Fatalf("status ok = %q; want \"sent to ...\"", iv.statusOK)
221- }
222-}
223-
224-func TestNormalizeSpawnReasoning(t *testing.T) {
225- cases := []struct {
226- in, want string
227- ok bool
228- }{
229- {in: "OFF", want: "off", ok: true},
230- {in: "minimal", want: "minimum", ok: true},
231- {in: "maximum", want: "xhigh", ok: true},
232- {in: "MAX", want: "max", ok: true},
233- {in: "bogus", ok: false},
234- }
235- for _, tc := range cases {
236- got, err := normalizeSpawnReasoning(tc.in)
237- if tc.ok {
238- if err != nil || got != tc.want {
239- t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
240- }
241- continue
242- }
243- if err == nil {
244- t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got)
245- }
246- }
247-}
248-
249-func TestParseSpawnFlags(t *testing.T) {
250- cases := []struct {
251- in string
252- wantModel, wantProv string
253- wantReasoning, wantSubagent string
254- wantTask string
255- }{
256- {"do x", "", "", "", "", "do x"},
257- {"--model claude do x", "claude", "", "", "", "do x"},
258- {"--model=claude do x", "claude", "", "", "", "do x"},
259- {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
260- {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
261- {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"},
262- {"--agent --reasoning high review auth", "", "", "high", "", "review auth"},
263- {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"},
264- {"--thinking=max do x", "", "", "max", "", "do x"},
265- // Only LEADING flags are consumed.
266- {"do --model x", "", "", "", "", "do --model x"},
267- // Missing value: --model with no follow-up token leaves model empty
268- // and the next field starts the task.
269- {"--model", "", "", "", "", ""},
270- }
271- for _, c := range cases {
272- m, p, r, a, task := parseSpawnFlags(c.in)
273- if m != c.wantModel || p != c.wantProv || r != c.wantReasoning || a != c.wantSubagent || task != c.wantTask {
274- t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q,%q,%q); want (%q,%q,%q,%q,%q)",
275- c.in, m, p, r, a, task, c.wantModel, c.wantProv, c.wantReasoning, c.wantSubagent, c.wantTask)
276- }
277- }
278-}
279-
280-func TestSplitIDAndRest(t *testing.T) {
281- cases := []struct {
282- in string
283- wantID string
284- wantText string
285- }{
286- {"", "", ""},
287- {" ", "", ""},
288- {"alpha", "alpha", ""},
289- {"alpha hello world", "alpha", "hello world"},
290- {" alpha hello world ", "alpha", "hello world "},
291- {"alpha\thi", "alpha", "hi"},
292- }
293- for _, c := range cases {
294- gotID, gotText := splitIDAndRest(c.in)
295- if gotID != c.wantID || gotText != c.wantText {
296- t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText)
297- }
298- }
299-}
300-
301-func TestRunSwarmWithoutSwarmIsNoop(t *testing.T) {
302- iv := &Interactive{
303- swarmDialog: newSwarmDialog(),
304- dirty: make(chan struct{}, 1),
305- }
306- // cfg.Swarm stays nil. The command should set a status err and
307- // otherwise be inert.
308- iv.runSwarm(context.Background(), nil)
309- if iv.swarmDialog.Active() {
310- t.Fatal("dialog opened despite no swarm")
311- }
312- if iv.statusErr == "" {
313- t.Fatal("expected a status error when swarm is nil")
314- }
315-}
M packages/agent/settings_store.go +14 -12
@@ -59,12 +59,12 @@ func (configSettingsStore) SetTerminalTitleEnabled(enabled bool) error {
5959 return SaveConfig(cfg)
6060 }
6161
62-func (configSettingsStore) SetAutoSwarm(enabled bool) error {
62+func (configSettingsStore) SetAutoSubagents(enabled bool) error {
6363 cfg, err := LoadConfig()
6464 if err != nil {
6565 return err
6666 }
67- cfg.AutoSwarmEnabled = &enabled
67+ cfg.AutoSubagentsEnabled = &enabled
6868 return SaveConfig(cfg)
6969 }
7070
@@ -212,24 +212,26 @@ func (configSettingsStore) SetTheme(name string) error {
212212 return SaveConfig(cfg)
213213 }
214214
215-// AutoSwarmEnabled reads the current auto-swarm flag from config.
216-// Used by the swarm_spawn tool at call time to gate execution.
217-func AutoSwarmEnabled() bool {
215+// AutoSubagentsEnabled reads the current auto-subagents flag from config.
216+// The build/CLI startup path uses it when initially injecting
217+// subagent_spawn into the tool registry; interactive mode mirrors the setting
218+// into the live tool registry when its settings toggle changes.
219+func AutoSubagentsEnabled() bool {
218220 cfg, err := LoadConfig()
219221 if err != nil {
220222 return false
221223 }
222- return cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled
224+ return cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled
223225 }
224226
225-// AutoSwarmSystemAddendum is appended to the system prompt when
226-// auto-swarm is enabled, so the model knows it may delegate to
227+// AutoSubagentsSystemAddendum is appended to the system prompt when
228+// auto-subagents is enabled, so the model knows it may delegate to
227229 // background sub-agents without the user having to mention the tool
228230 // by name. Kept short so it doesn't bloat the cached prompt prefix.
229-const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn tool that forks background sub-agents working in parallel in this same working directory.
231+const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. You have a subagent_spawn tool that starts background sub-agents in separate long-lived processes.
230232
231-Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). If [subagents_list] is present, choose the named profile whose description best matches the task and pass its name as the tool's agent field. Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing.
233+Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently; prefer isolation:"worktree" for parallel coding (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). If [subagents_list] is present, choose the named profile whose description best matches the task and pass its name as the tool's agent field. Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to summarize the outcome and decide any follow-up.
232234
233-Do NOT use swarm_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself.
235+Do NOT use subagent_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself. Child workers cannot recursively spawn more sub-agents in v1.
234236
235-When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-swarm update] message recapping each agent's status, task, and transcript tail. Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing the agents by id.`
237+When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-subagents update] message containing each agent's id, status, task, optional error, and final response (or a tail when the response is long). Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing each agent by id.`
A packages/agent/subagent_worker.go +541 -0
@@ -0,0 +1,541 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "strconv"
11+ "strings"
12+ "sync"
13+ "time"
14+ "unicode/utf8"
15+
16+ "github.com/patriceckhart/zot/packages/agent/modes"
17+ "github.com/patriceckhart/zot/packages/agent/subagents"
18+ "github.com/patriceckhart/zot/packages/core"
19+ "github.com/patriceckhart/zot/packages/provider"
20+)
21+
22+// runSubagentWorkerMode is the daemon-mode entry point used by every
23+// subagent-spawned zot subprocess. It's intentionally close in shape to
24+// runJSONMode but with two key differences:
25+//
26+// - Lifetime: the process stays alive across many user turns. The
27+// initial positional task (if any) is the first turn; subsequent
28+// turns arrive through the inbox unix socket at args.SubagentWorker.
29+//
30+// - Output: every emitted JSON line is also mirrored verbatim into
31+// events.jsonl (see ZOT_SUBAGENT_EVENT_LOG) so a separate zot
32+// process can /subagents open this agent and replay its full history
33+// even after the parent that spawned us is long gone.
34+//
35+// The runner in packages/agent/subagents/runner.go is the only caller in
36+// production; tests use the stubchild binary under
37+// packages/agent/subagents/testdata/cmd/stubchild instead of the real model
38+// loop.
39+func runSubagentWorkerMode(ctx context.Context, args Args, version string) error {
40+ if args.SubagentWorker == "" {
41+ return fmt.Errorf("--subagent-worker requires a socket path")
42+ }
43+ if os.Getenv("ZOT_SUBAGENT_CREDENTIAL_STDIN") == "1" {
44+ var inherited subagents.Credential
45+ dec := json.NewDecoder(io.LimitReader(os.Stdin, 1<<20))
46+ if err := dec.Decode(&inherited); err != nil {
47+ return fmt.Errorf("read inherited subagent credential: %w", err)
48+ }
49+ if inherited.Value == "" || inherited.Method == "" {
50+ return fmt.Errorf("read inherited subagent credential: missing value or method")
51+ }
52+ args.inheritedCredential = inherited.Value
53+ args.inheritedAuthMethod = inherited.Method
54+ args.inheritedAccountID = inherited.AccountID
55+ }
56+
57+ r, err := Resolve(args, true)
58+ if err != nil {
59+ return err
60+ }
61+ extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
62+ defer stopExt()
63+
64+ ag := r.NewAgent()
65+ initialAg := ag
66+ defer func() {
67+ closeAgentLSP(ag)
68+ if ag != initialAg {
69+ closeAgentLSP(initialAg)
70+ }
71+ }()
72+ wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
73+ sess, err := openOrCreateSession(args, r, ag, version)
74+ if err != nil {
75+ return err
76+ }
77+ if sess != nil {
78+ var providerName, model string
79+ sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
80+ if err != nil {
81+ return err
82+ }
83+ r.Provider, r.Model = providerName, model
84+ ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
85+ defer sess.Close()
86+ }
87+ announceSession(extMgr, sess)
88+
89+ // Open the inbox listener BEFORE emitting agent_ready so the
90+ // supervisor can dial through on the very first send. The
91+ // subagent supervisor's Inbox retries dialing for a
92+ // short window, but emitting ready first and then listening
93+ // would still race in tight loops.
94+ ln, err := subagents.Listen(args.SubagentWorker)
95+ if err != nil {
96+ return fmt.Errorf("subagent-worker listen: %w", err)
97+ }
98+ defer ln.Close()
99+
100+ // Event log is owned by the supervisor's runner via stdout, but
101+ // the daemon also writes a redundant copy here when the runner's
102+ // pipe is closed (e.g. parent zot exited but the agent is still
103+ // running headless). The env var is set by the runner; if it's
104+ // empty we silently skip the second mirror.
105+ var logMirror *subagents.EventLog
106+ if path := os.Getenv("ZOT_SUBAGENT_EVENT_LOG"); path != "" {
107+ logMirror, _ = subagents.OpenEventLog(path)
108+ }
109+ if logMirror != nil {
110+ defer logMirror.Close()
111+ }
112+
113+ em := newSubagentEmitter(os.Stdout, logMirror)
114+ em.setProtocolIdentity(os.Getenv("ZOT_SUBAGENT_AGENT_ID"))
115+ em.emit("agent.ready", map[string]any{
116+ "version": version,
117+ "cwd": r.CWD,
118+ "model": r.Model,
119+ })
120+
121+ // Heartbeats make a live-but-detached child distinguishable from a
122+ // failed turn after supervisor reconnect. The parent still treats
123+ // unknown event types as forward-compatible.
124+ heartbeatDone := make(chan struct{})
125+ defer close(heartbeatDone)
126+ go func() {
127+ interval := 10 * time.Second
128+ if raw := os.Getenv("ZOT_SUBAGENT_HEARTBEAT_INTERVAL"); raw != "" {
129+ if parsed, parseErr := time.ParseDuration(raw); parseErr == nil && parsed > 0 {
130+ interval = parsed
131+ }
132+ }
133+ ticker := time.NewTicker(interval)
134+ defer ticker.Stop()
135+ for {
136+ select {
137+ case <-heartbeatDone:
138+ return
139+ case <-ticker.C:
140+ em.emit("agent.heartbeat", map[string]any{"activity": "alive"})
141+ }
142+ }
143+ }()
144+
145+ // Keep a per-turn cancel so the "cancel" inbox message can interrupt
146+ // an in-flight turn without tearing down the whole daemon.
147+ var (
148+ mu sync.Mutex
149+ cancelFn context.CancelFunc
150+ busyTurn bool
151+ turnNo = initialTurnNumber(os.Getenv("ZOT_SUBAGENT_EVENT_LOG"))
152+ turnWG sync.WaitGroup
153+ closing bool
154+ shutdown = make(chan struct{})
155+ shutdownOnce sync.Once
156+ )
157+ maxOutputBytes, maxOutputLines := workerOutputLimits()
158+
159+ runOne := func(prompt string) {
160+ mu.Lock()
161+ // launchTurn admits the goroutine to turnWG before it starts so
162+ // shutdown can join it. A shutdown can win the scheduling race
163+ // after that admission but before this goroutine acquires mu; in
164+ // that case do not start a new Prompt after waitForActiveTurn has
165+ // declared the worker closing.
166+ if closing {
167+ mu.Unlock()
168+ return
169+ }
170+ if args.SubagentMaxTurns > 0 && turnNo >= args.SubagentMaxTurns {
171+ // Consume a unique rejected attempt so repeated prompts after
172+ // the limit cannot reuse the same turn id in the event log.
173+ turnNo++
174+ step := turnNo
175+ mu.Unlock()
176+ turnID := fmt.Sprintf("turn-%d", step)
177+ errPayload := map[string]any{"code": "max_turns", "message": "maximum subagent turns reached"}
178+ em.emit("turn.result", map[string]any{"status": "failed", "turn_id": turnID, "error": errPayload})
179+ em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": errPayload})
180+ em.emit("turn_end", map[string]any{"step": step, "turn_id": turnID, "error": errPayload["message"]})
181+ em.emit("agent.idle", map[string]any{"turn_id": turnID})
182+ return
183+ }
184+ if busyTurn {
185+ // Drop concurrent turns rather than queuing. The
186+ // supervisor protocol assumes one outstanding turn per
187+ // agent; if a user really wants to interrupt and start
188+ // another, they should send "cancel" first.
189+ mu.Unlock()
190+ em.emit("error", map[string]any{"message": "agent busy; send 'cancel' first"})
191+ return
192+ }
193+ busyTurn = true
194+ turnNo++
195+ step := turnNo
196+ c, cancel := context.WithCancel(ctx)
197+ cancelFn = cancel
198+ mu.Unlock()
199+
200+ turnID := fmt.Sprintf("turn-%d", step)
201+ em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID})
202+
203+ sink := func(ev core.AgentEvent) {
204+ em.emit(ev.Type(), modes.EventToJSON(ev))
205+ }
206+
207+ start := len(ag.Messages())
208+ err := ag.Prompt(c, prompt, nil, sink)
209+ WriteNewTranscript(ag, sess, start)
210+
211+ output := finalAssistantText(ag.Messages()[start:])
212+ resultStatus := "succeeded"
213+ if err != nil {
214+ if errors.Is(err, context.Canceled) {
215+ resultStatus = "canceled"
216+ } else {
217+ resultStatus = "failed"
218+ }
219+ }
220+ resultPayload := map[string]any{
221+ "status": resultStatus,
222+ "turn_id": turnID,
223+ "summary": boundedResultSummary(output),
224+ "output": boundedResultOutput(output, maxOutputBytes, maxOutputLines),
225+ "error": resultErrorPayload(err),
226+ }
227+ em.emit("turn.result", resultPayload)
228+ if err != nil {
229+ em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": resultErrorPayload(err)})
230+ }
231+ em.emit("turn_end", map[string]any{
232+ "step": step,
233+ "turn_id": turnID,
234+ "error": errString(err),
235+ })
236+ em.emit("agent.idle", map[string]any{"turn_id": turnID})
237+
238+ cancel()
239+ mu.Lock()
240+ busyTurn = false
241+ cancelFn = nil
242+ mu.Unlock()
243+ }
244+
245+ launchTurn := func(prompt string) {
246+ mu.Lock()
247+ if closing {
248+ mu.Unlock()
249+ return
250+ }
251+ // Register the goroutine before launching it. Shutdown can then
252+ // close the gate and wait for every turn that was admitted, even
253+ // if runOne has not acquired mu and set busyTurn yet.
254+ turnWG.Add(1)
255+ mu.Unlock()
256+ go func() {
257+ defer turnWG.Done()
258+ runOne(prompt)
259+ }()
260+ }
261+
262+ waitForActiveTurn := func(cancel bool) {
263+ mu.Lock()
264+ closing = true
265+ activeCancel := cancelFn
266+ mu.Unlock()
267+ if cancel && activeCancel != nil {
268+ activeCancel()
269+ }
270+ turnWG.Wait()
271+ }
272+
273+ requestShutdown := func() {
274+ mu.Lock()
275+ closing = true
276+ mu.Unlock()
277+ shutdownOnce.Do(func() { close(shutdown) })
278+ }
279+
280+ // Initial task: run before processing the inbox so the agent
281+ // "starts working" the moment it boots, matching what users
282+ // expect from `/subagents new <task>`.
283+ if args.Prompt != "" {
284+ launchTurn(args.Prompt)
285+ }
286+
287+ // Inbox loop: one supervisor message at a time. We don't spawn
288+ // a goroutine per turn because runOne already serialises them
289+ // via the busyTurn flag; doing the dispatch on the main
290+ // goroutine keeps the daemon's lifecycle easy to follow.
291+ for {
292+ select {
293+ case <-ctx.Done():
294+ waitForActiveTurn(true)
295+ em.emit("agent.exited", map[string]any{"reason": "cancelled"})
296+ return ctx.Err()
297+ case <-shutdown:
298+ // Shutdown is an explicit supervisor stop. Cancel an active
299+ // turn before joining it so a detached worker cannot remain
300+ // alive indefinitely while the host waits for its socket to
301+ // disappear.
302+ waitForActiveTurn(true)
303+ em.emit("agent.exited", map[string]any{"reason": "shutdown"})
304+ return nil
305+ case msg, ok := <-ln.Lines():
306+ if !ok {
307+ waitForActiveTurn(true)
308+ em.emit("agent.exited", map[string]any{"reason": "inbox-closed"})
309+ return nil
310+ }
311+ command, parseErr := subagents.ParseCommand(msg)
312+ if parseErr != nil {
313+ em.emit("error", map[string]any{"message": "invalid supervisor command"})
314+ continue
315+ }
316+ switch command.Type {
317+ case subagents.CommandAgentShutdown:
318+ requestShutdown()
319+ case subagents.CommandTurnCancel:
320+ mu.Lock()
321+ if cancelFn != nil {
322+ cancelFn()
323+ }
324+ mu.Unlock()
325+ case subagents.CommandAgentPing:
326+ em.emit("agent.heartbeat", map[string]any{"activity": "alive"})
327+ case subagents.CommandTurnStart:
328+ var payload subagents.TurnStartPayload
329+ if err := command.DecodePayload(&payload); err != nil {
330+ em.emit("error", map[string]any{"message": "invalid turn.start payload"})
331+ continue
332+ }
333+ launchTurn(payload.Prompt)
334+ default:
335+ // Unknown commands are rejected rather than interpreted as
336+ // child-originated control requests.
337+ em.emit("error", map[string]any{"message": "unknown supervisor command"})
338+ }
339+ }
340+ }
341+}
342+
343+// subagentEmitter serialises events to stdout and (optionally) to a
344+// durable log file. Concurrent goroutines call emit so we have to
345+// hold a mutex around the encoder.
346+type subagentEmitter struct {
347+ mu sync.Mutex
348+ w *os.File
349+ mirror *subagents.EventLog
350+ agentID string
351+
352+ // orphan flips true the first time a stdout write fails (broken
353+ // pipe — the supervisor died). Until then the mirror stays
354+ // dormant: the supervisor is the canonical writer to events.jsonl
355+ // (it parses our stdout and Append()s each event itself). Writing
356+ // from both sides used to land every event in the log twice,
357+ // which showed up as a fully-duplicated transcript the next time
358+ // the agent was reloaded — the exact "why is everything doubled"
359+ // bug. Once orphaned the mirror takes over so the events still
360+ // land on disk for the next reload.
361+ orphan bool
362+}
363+
364+func newSubagentEmitter(w *os.File, mirror *subagents.EventLog) *subagentEmitter {
365+ return &subagentEmitter{w: w, mirror: mirror}
366+}
367+
368+func (e *subagentEmitter) setProtocolIdentity(agentID string) {
369+ e.mu.Lock()
370+ e.agentID = agentID
371+ e.mu.Unlock()
372+}
373+
374+func (e *subagentEmitter) emit(typ string, data map[string]any) {
375+ if data == nil {
376+ data = map[string]any{}
377+ }
378+ payload := make(map[string]any, len(data))
379+ for key, value := range data {
380+ payload[key] = value
381+ }
382+ turnID, _ := payload["turn_id"].(string)
383+
384+ e.mu.Lock()
385+ defer e.mu.Unlock()
386+
387+ wireType := canonicalWorkerEvent(typ)
388+ envelope := subagents.NewEventEnvelope(wireType, e.agentID, turnID, payload)
389+ line, err := subagents.MarshalJSONL(envelope)
390+
391+ if !e.orphan && err == nil {
392+ if _, werr := e.w.Write(line); werr != nil {
393+ // Supervisor's stdout pipe is gone (parent zot exited but we
394+ // kept running). Switch to mirror-only mode and preserve this
395+ // event in the durable log.
396+ e.orphan = true
397+ }
398+ }
399+
400+ if e.orphan && e.mirror != nil {
401+ _ = e.mirror.Append(subagents.NewEvent(wireType, payload))
402+ }
403+}
404+
405+func canonicalWorkerEvent(typ string) string {
406+ switch typ {
407+ case "agent_ready":
408+ return subagents.EventAgentReady
409+ case "agent_stopped":
410+ return subagents.EventAgentExited
411+ case "turn_start":
412+ return subagents.EventTurnStarted
413+ case "turn_progress":
414+ return subagents.EventTurnProgress
415+ case "tool_call":
416+ return subagents.EventToolStarted
417+ case "tool_result":
418+ return subagents.EventToolFinished
419+ case "text_delta":
420+ return subagents.EventMessageDelta
421+ default:
422+ return typ
423+ }
424+}
425+
426+func finalAssistantText(messages []provider.Message) string {
427+ var out strings.Builder
428+ for _, message := range messages {
429+ if message.Role != provider.RoleAssistant {
430+ continue
431+ }
432+ for _, block := range message.Content {
433+ if text, ok := block.(provider.TextBlock); ok {
434+ if out.Len() > 0 {
435+ out.WriteByte('\n')
436+ }
437+ out.WriteString(text.Text)
438+ }
439+ }
440+ }
441+ return out.String()
442+}
443+
444+func firstLine(text string) string {
445+ return strings.Split(strings.TrimSpace(text), "\n")[0]
446+}
447+
448+func boundedResultSummary(text string) string {
449+ return boundedResultOutput(firstLine(text), 4*1024, 1)
450+}
451+
452+func workerOutputLimits() (maxBytes, maxLines int) {
453+ maxBytes, maxLines = 500_000, 5_000
454+ if value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("ZOT_SUBAGENT_MAX_OUTPUT_BYTES"))); err == nil && value > 0 {
455+ maxBytes = value
456+ }
457+ if value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("ZOT_SUBAGENT_MAX_OUTPUT_LINES"))); err == nil && value > 0 {
458+ maxLines = value
459+ }
460+ return maxBytes, maxLines
461+}
462+
463+func boundedResultOutput(text string, maxBytes, maxLines int) string {
464+ const marker = "...[output truncated]"
465+ lines := strings.Split(text, "\n")
466+ if maxLines > 0 && len(lines) > maxLines {
467+ if maxLines == 1 {
468+ text = marker
469+ } else {
470+ text = strings.Join(append(lines[:maxLines-1], marker), "\n")
471+ }
472+ }
473+ if maxBytes > 0 && len([]byte(text)) > maxBytes {
474+ if maxBytes <= len(marker) {
475+ return truncateOutputUTF8(text, maxBytes)
476+ }
477+ return truncateOutputUTF8(text, maxBytes-len(marker)) + marker
478+ }
479+ return text
480+}
481+
482+func truncateOutputUTF8(value string, maxBytes int) string {
483+ if maxBytes <= 0 {
484+ return ""
485+ }
486+ data := []byte(value)
487+ if len(data) <= maxBytes {
488+ return value
489+ }
490+ data = data[:maxBytes]
491+ for len(data) > 0 && !utf8.Valid(data) {
492+ data = data[:len(data)-1]
493+ }
494+ return string(data)
495+}
496+
497+func resultErrorPayload(err error) map[string]any {
498+ if err == nil {
499+ return nil
500+ }
501+ return map[string]any{"code": "turn_failed", "message": truncateForLog(err.Error(), 500)}
502+}
503+
504+func errString(err error) string {
505+ if err == nil {
506+ return ""
507+ }
508+ return err.Error()
509+}
510+
511+func initialTurnNumber(path string) int {
512+ if strings.TrimSpace(path) == "" {
513+ return 0
514+ }
515+ events, err := subagents.ReadEventLog(path)
516+ if err != nil {
517+ return 0
518+ }
519+ maxStep := 0
520+ for _, ev := range events {
521+ if step, ok := ev.Data["step"].(float64); ok && int(step) > maxStep {
522+ maxStep = int(step)
523+ }
524+ if strings.HasPrefix(ev.TurnID, "turn-") {
525+ if n, parseErr := strconv.Atoi(strings.TrimPrefix(ev.TurnID, "turn-")); parseErr == nil && n > maxStep {
526+ maxStep = n
527+ }
528+ }
529+ }
530+ return maxStep
531+}
532+
533+func truncateForLog(s string, n int) string {
534+ if len(s) <= n {
535+ return s
536+ }
537+ if n <= 3 {
538+ return strings.Repeat(".", n)
539+ }
540+ return s[:n-3] + "..."
541+}
A packages/agent/subagent_worker_test.go +188 -0
@@ -0,0 +1,188 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+
12+ "github.com/patriceckhart/zot/packages/agent/subagents"
13+)
14+
15+// TestWorkerOutputLimitsUseSupervisorPolicy verifies that the child reads
16+// the effective output caps propagated by the supervisor and retains safe
17+// defaults for standalone workers.
18+func TestWorkerOutputLimitsUseSupervisorPolicy(t *testing.T) {
19+ t.Setenv("ZOT_SUBAGENT_MAX_OUTPUT_BYTES", "123")
20+ t.Setenv("ZOT_SUBAGENT_MAX_OUTPUT_LINES", "7")
21+ bytesLimit, linesLimit := workerOutputLimits()
22+ if bytesLimit != 123 || linesLimit != 7 {
23+ t.Fatalf("worker output limits = (%d, %d), want (123, 7)", bytesLimit, linesLimit)
24+ }
25+ t.Setenv("ZOT_SUBAGENT_MAX_OUTPUT_BYTES", "invalid")
26+ t.Setenv("ZOT_SUBAGENT_MAX_OUTPUT_LINES", "0")
27+ bytesLimit, linesLimit = workerOutputLimits()
28+ if bytesLimit != 500_000 || linesLimit != 5_000 {
29+ t.Fatalf("invalid worker output limits = (%d, %d), want defaults", bytesLimit, linesLimit)
30+ }
31+}
32+
33+// TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the
34+// "everything is doubled after reopening a subagent agent" bug.
35+//
36+// Symptom: events.jsonl held two copies of every event because the
37+// child mirrored each event to disk AND the supervisor parsed the
38+// child's stdout and appended each event to disk too. On next zot
39+// launch the replay produced two transcript lines per real one.
40+//
41+// Fix invariant: while stdout writes succeed (i.e. the supervisor is
42+// alive on the other end of the pipe), the child's mirror writes
43+// NOTHING. Only when a stdout write fails (broken pipe → orphan)
44+// does the mirror take over so events still get persisted.
45+func TestSupervisorEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) {
46+ // Real *os.File for the emitter's stdout-equivalent so the
47+ // emitter's write() path (which expects *os.File) actually runs.
48+ stdoutPath := filepath.Join(t.TempDir(), "stdout.fifo")
49+ stdoutFile, err := os.Create(stdoutPath)
50+ if err != nil {
51+ t.Fatalf("create stdout file: %v", err)
52+ }
53+ defer stdoutFile.Close()
54+
55+ // Mirror writes go to a separate events.jsonl that we can read
56+ // at the end to assert how many events the mirror emitted.
57+ mirrorPath := filepath.Join(t.TempDir(), "events.jsonl")
58+ mirror, err := subagents.OpenEventLog(mirrorPath)
59+ if err != nil {
60+ t.Fatalf("open mirror: %v", err)
61+ }
62+ defer mirror.Close()
63+
64+ em := newSubagentEmitter(stdoutFile, mirror)
65+
66+ // Healthy stdout: emit three events. Mirror must stay empty.
67+ em.emit("turn_start", map[string]any{"step": 1})
68+ em.emit("assistant_message", map[string]any{"text": "hi"})
69+ em.emit("turn_end", map[string]any{"step": 1})
70+
71+ got, err := subagents.ReadEventLog(mirrorPath)
72+ if err != nil {
73+ t.Fatalf("read mirror: %v", err)
74+ }
75+ if len(got) != 0 {
76+ t.Fatalf("mirror wrote %d events while supervisor was alive; want 0 (every event would otherwise double on the next reload)\n%+v",
77+ len(got), got)
78+ }
79+
80+ // Simulate supervisor death: close stdout so the next Write
81+ // returns EBADF / broken pipe. The emitter must flip into
82+ // orphan mode and start writing through the mirror.
83+ if err := stdoutFile.Close(); err != nil {
84+ t.Fatalf("close stdout: %v", err)
85+ }
86+
87+ em.emit("assistant_message", map[string]any{"text": "after orphan"})
88+ em.emit("turn_end", map[string]any{"step": 2})
89+
90+ got, err = subagents.ReadEventLog(mirrorPath)
91+ if err != nil {
92+ t.Fatalf("read mirror post-orphan: %v", err)
93+ }
94+ if len(got) < 2 {
95+ t.Fatalf("mirror failed to take over after stdout died: got %d events", len(got))
96+ }
97+ if got[len(got)-1].Type != "turn_end" {
98+ t.Errorf("last mirrored event type = %q; want turn_end", got[len(got)-1].Type)
99+ }
100+}
101+
102+// TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire regresses the
103+// large-result wire duplication bug.
104+func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) {
105+ path := filepath.Join(t.TempDir(), "events.jsonl")
106+ file, err := os.Create(path)
107+ if err != nil {
108+ t.Fatal(err)
109+ }
110+ defer file.Close()
111+
112+ em := newSubagentEmitter(file, nil)
113+ em.setProtocolIdentity("agent-1")
114+ em.emit("turn.result", map[string]any{
115+ "status": "succeeded",
116+ "turn_id": "turn-1",
117+ "output": strings.Repeat("x", 500_000),
118+ })
119+ if err := file.Close(); err != nil {
120+ t.Fatal(err)
121+ }
122+ wire, err := os.ReadFile(path)
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ if len(wire) >= 900_000 {
127+ t.Fatalf("large result appears duplicated on wire: %d bytes", len(wire))
128+ }
129+ var object map[string]any
130+ if err := json.Unmarshal(bytes.TrimSpace(wire), &object); err != nil {
131+ t.Fatal(err)
132+ }
133+ if _, exists := object["output"]; exists {
134+ t.Fatal("large output was flattened a second time")
135+ }
136+ payload, ok := object["payload"].(map[string]any)
137+ if !ok || len(payload["output"].(string)) != 500_000 {
138+ t.Fatal("canonical payload lost the complete bounded output")
139+ }
140+ events, err := subagents.ReadEventLog(path)
141+ if err != nil || len(events) != 1 {
142+ t.Fatalf("supervisor event parser could not recover the large result: events=%d err=%v", len(events), err)
143+ }
144+ if output, ok := events[0].Data["output"].(string); !ok || len(output) != 500_000 {
145+ t.Fatal("supervisor parser lost the large result payload")
146+ }
147+}
148+
149+// TestSupervisorEmitterStdoutShapeMatchesSupervisorParser pins the
150+// wire-format contract: each emitted event lands on stdout as one
151+// JSON object per line with type+time at top level alongside the
152+// data fields. The supervisor's runner parses this exact shape.
153+func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
154+ // Pipe so we can read what the emitter wrote.
155+ r, w, err := os.Pipe()
156+ if err != nil {
157+ t.Fatalf("pipe: %v", err)
158+ }
159+ defer r.Close()
160+
161+ em := newSubagentEmitter(w, nil)
162+ em.emit("turn_start", map[string]any{"step": 1})
163+ _ = w.Close()
164+
165+ body, err := io.ReadAll(r)
166+ if err != nil {
167+ t.Fatalf("read pipe: %v", err)
168+ }
169+ // One trailing newline => one event line.
170+ lines := bytes.Split(bytes.TrimRight(body, "\n"), []byte("\n"))
171+ if len(lines) != 1 {
172+ t.Fatalf("expected 1 event line, got %d: %q", len(lines), body)
173+ }
174+ var object map[string]any
175+ if err := json.Unmarshal(lines[0], &object); err != nil {
176+ t.Fatalf("not valid json: %v\n%s", err, lines[0])
177+ }
178+ if object["type"] != "turn.started" {
179+ t.Errorf("type field missing or wrong: %v", object["type"])
180+ }
181+ if _, ok := object["timestamp"].(string); !ok {
182+ t.Errorf("timestamp field missing: %v", object["timestamp"])
183+ }
184+ payload, ok := object["payload"].(map[string]any)
185+ if !ok || payload["step"] != float64(1) {
186+ t.Errorf("payload step field missing or wrong: %v", object["payload"])
187+ }
188+}
A packages/agent/subagents/agent.go +481 -0
@@ -0,0 +1,481 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "fmt"
6+ "path/filepath"
7+ "strings"
8+ "sync"
9+ "time"
10+)
11+
12+// Agent is one supervised task. Public fields are immutable after
13+// Spawn; mutable state (status, activity, transcript) lives behind
14+// the embedded mutex.
15+type turnEndNotice struct {
16+ step int
17+ errMsg string
18+}
19+
20+type Agent struct {
21+ ID string
22+ Task string
23+ Dir string // shared checkout or isolated worktree used by the child.
24+ Started time.Time
25+
26+ // ParentID and RootSessionID identify the supervisor/session that owns
27+ // this worker. Empty ParentID means the main supervisor created it;
28+ // child-originated spawning is rejected in v1.
29+ ParentID string
30+ BatchID string
31+ RootSessionID string
32+ OriginalTask string
33+ RepositoryRoot string
34+ WorkspaceMode WorkspaceMode
35+ WorkspacePath string
36+ WorkspaceBase string
37+ WorkspaceCapture CaptureMode
38+ MaxTurns int
39+ // Timeout is the effective per-agent lifetime, persisted so a resumed
40+ // worker keeps the timeout selected at its spawn boundary.
41+ Timeout time.Duration
42+ HeartbeatInterval time.Duration
43+ Tools []string
44+
45+ // Model and Provider, when non-empty, override the child
46+ // subprocess's model resolution. BaseURL and InsecureTLS carry
47+ // the effective provider connection settings from the supervisor.
48+ // Empty values inherit whatever the child resolves on its own from
49+ // config / env / flags. All four settings are persisted in meta.json
50+ // so Resume keeps using the same provider configuration across zot
51+ // restarts.
52+ Model string
53+ Provider string
54+ BaseURL string
55+ InsecureTLS bool
56+ Reasoning string
57+ FastMode bool
58+ Subagent string
59+
60+ // SessionID, when non-empty, scopes the agent to a particular
61+ // host zot session: the dashboard only surfaces agents whose
62+ // SessionID matches the active session. Empty means "unscoped" and
63+ // applies to agents spawned without a session context such as in tests. Set at
64+ // Spawn time from Supervisor.activeSession and persisted in
65+ // meta.json so the scope survives restarts.
66+ SessionID string
67+
68+ // Resuming is true when this Agent struct was built by Resume
69+ // rather than Spawn. The runner consults it to decide whether
70+ // to pass the original Task as a positional argv to the child:
71+ // on first Spawn we want the child to run the task immediately;
72+ // on Resume the task was already run last time and replaying it
73+ // would produce a second turn that collides ("agent busy; send
74+ // 'cancel' first") with whatever the user types next. Not
75+ // persisted — every Resume sets it explicitly.
76+ Resuming bool
77+
78+ // InboxPath is the unix socket the child agent listens on for
79+ // follow-up prompts and control messages. The supervisor opens
80+ // an Inbox at this path; the child opens a Listener.
81+ InboxPath string
82+
83+ // Attempt increments whenever the supervisor starts a process for this
84+ // agent. It is persisted so recovery can distinguish a resumed worker
85+ // from a first launch.
86+ Attempt int
87+
88+ // ProcessPID identifies the current worker when known. It is persisted
89+ // so a reloaded supervisor can force-kill a detached worker after a
90+ // graceful shutdown deadline.
91+ ProcessPID int
92+
93+ // EventLogPath is the durable JSONL event log for this agent.
94+ // The runner appends every well-formed event from the child
95+ // (plus lifecycle events of its own) here. /subagents open in any
96+ // zot process reads from this file to replay the full history.
97+ EventLogPath string
98+
99+ // SessionPath is the child's persistent session file. Surfaced
100+ // so the dashboard / /subagents open can resume the agent later.
101+ SessionPath string
102+
103+ // inbox is the supervisor-side socket handle. Populated by
104+ // Supervisor.Spawn so callers do not need to manage the socket directly.
105+ inbox *Inbox
106+
107+ mu sync.Mutex
108+ status Status
109+ activity string
110+ transcript []string
111+ lastAssistant string
112+ finished time.Time
113+ lastErr error
114+
115+ lifecycleMu sync.Mutex
116+ processState ProcessState
117+ turnState TurnState
118+ currentTurnID string
119+ updatedAt time.Time
120+ lastActivity time.Time
121+ result *TurnResult
122+ resultRef string
123+ patchRef string
124+ changedFiles []string
125+ outputBytes int
126+ outputLines int
127+ outputTruncated bool
128+ maxOutputBytes int
129+ maxOutputLines int
130+ persistFn func(*Agent)
131+ stateDir string
132+ workspaceCleanup func() error
133+ workspaceCapture func() (WorkspaceCapture, error)
134+
135+ // OnTurnEnd, if set, fires once per prompt-level turn_end event
136+ // emitted by the subagent daemon wrapper. Provider/tool-loop
137+ // turn_end events (for example stop=tool_use) are ignored by the
138+ // runner because they do not mean the sub-agent task finished.
139+ // Used by auto-subagents watchers to detect that a sub-agent's first
140+ // (or n-th) task has finished without waiting for the long-lived
141+ // daemon itself to exit — sub-agents keep running on the inbox
142+ // even after the initial task completes, so Wait() never unblocks
143+ // for them.
144+ OnTurnEnd func(step int, errMsg string)
145+ pendingTurnEnds []turnEndNotice
146+
147+ ctx context.Context
148+ cancel context.CancelFunc
149+ runner Runner
150+ lease *agentLease
151+
152+ // launchState is an admission gate for the scheduler's goroutine:
153+ // 0=not admitted, 1=runner admitted, 2=stopped before admission. It
154+ // prevents an immediate Stop from starting a runner after cancellation.
155+ launchState int32
156+
157+ // done closes when the run goroutine finalises the agent's
158+ // status (done / failed / killed). Wait blocks on this so
159+ // callers don't have to poll.
160+ done chan struct{}
161+ turnResults chan *TurnResult
162+ doneOnce sync.Once
163+}
164+
165+// Inbox exposes the supervisor-side socket handle. Returns nil for
166+// agents without inboxes (e.g. tests using a custom Runner that
167+// doesn't speak the daemon protocol).
168+func (a *Agent) Inbox() *Inbox { return a.inbox }
169+
170+// Status returns the current high-level status. Cheap; safe from any goroutine.
171+func (a *Agent) Status() Status {
172+ a.mu.Lock()
173+ defer a.mu.Unlock()
174+ return a.status
175+}
176+
177+// Activity returns the current one-line activity string.
178+func (a *Agent) Activity() string {
179+ a.mu.Lock()
180+ defer a.mu.Unlock()
181+ return a.activity
182+}
183+
184+// ProcessState returns the child process lifecycle state.
185+func (a *Agent) ProcessState() ProcessState {
186+ a.lifecycleMu.Lock()
187+ defer a.lifecycleMu.Unlock()
188+ return a.processState
189+}
190+
191+// TurnState returns the current delegated-turn state.
192+func (a *Agent) TurnState() TurnState {
193+ a.lifecycleMu.Lock()
194+ defer a.lifecycleMu.Unlock()
195+ return a.turnState
196+}
197+
198+// CurrentTurnID returns the stable id of the current or most recent turn.
199+func (a *Agent) CurrentTurnID() string {
200+ a.lifecycleMu.Lock()
201+ defer a.lifecycleMu.Unlock()
202+ return a.currentTurnID
203+}
204+
205+// AttemptValue returns the current process attempt number.
206+func (a *Agent) AttemptValue() int {
207+ a.lifecycleMu.Lock()
208+ defer a.lifecycleMu.Unlock()
209+ return a.Attempt
210+}
211+
212+// ProcessPIDValue returns the current worker pid, when known.
213+func (a *Agent) ProcessPIDValue() int {
214+ a.lifecycleMu.Lock()
215+ defer a.lifecycleMu.Unlock()
216+ return a.ProcessPID
217+}
218+
219+// LastActivity returns the last heartbeat or meaningful child event time.
220+func (a *Agent) LastActivity() time.Time {
221+ a.lifecycleMu.Lock()
222+ defer a.lifecycleMu.Unlock()
223+ return a.lastActivity
224+}
225+
226+// Result returns a copy of the most recent structured turn result.
227+func (a *Agent) Result() *TurnResult {
228+ a.lifecycleMu.Lock()
229+ defer a.lifecycleMu.Unlock()
230+ return cloneTurnResult(a.result)
231+}
232+
233+func (a *Agent) setProcessState(state ProcessState) {
234+ a.lifecycleMu.Lock()
235+ a.processState = state
236+ a.updatedAt = time.Now()
237+ a.lifecycleMu.Unlock()
238+}
239+
240+func (a *Agent) incrementAttempt() int {
241+ a.lifecycleMu.Lock()
242+ a.Attempt++
243+ a.updatedAt = time.Now()
244+ attempt := a.Attempt
245+ a.lifecycleMu.Unlock()
246+ return attempt
247+}
248+
249+func (a *Agent) setProcessPID(pid int) {
250+ a.lifecycleMu.Lock()
251+ a.ProcessPID = pid
252+ a.updatedAt = time.Now()
253+ a.lifecycleMu.Unlock()
254+}
255+
256+func (a *Agent) releaseLease() error {
257+ a.lifecycleMu.Lock()
258+ lease := a.lease
259+ a.lease = nil
260+ a.lifecycleMu.Unlock()
261+ if lease == nil {
262+ return nil
263+ }
264+ return lease.Close()
265+}
266+
267+func (a *Agent) setTurnState(state TurnState, turnID string) {
268+ a.lifecycleMu.Lock()
269+ a.turnState = state
270+ if turnID != "" {
271+ a.currentTurnID = turnID
272+ }
273+ a.updatedAt = time.Now()
274+ a.lifecycleMu.Unlock()
275+}
276+
277+func (a *Agent) markActivity(now time.Time) {
278+ if now.IsZero() {
279+ now = time.Now()
280+ }
281+ a.lifecycleMu.Lock()
282+ a.lastActivity = now
283+ a.updatedAt = now
284+ a.lifecycleMu.Unlock()
285+}
286+
287+func (a *Agent) setResult(result *TurnResult) {
288+ a.lifecycleMu.Lock()
289+ a.result = cloneTurnResult(result)
290+ a.updatedAt = time.Now()
291+ a.lifecycleMu.Unlock()
292+}
293+
294+// Transcript returns a copy of the running transcript.
295+func (a *Agent) Transcript() []string {
296+ a.mu.Lock()
297+ defer a.mu.Unlock()
298+ out := make([]string, len(a.transcript))
299+ copy(out, a.transcript)
300+ return out
301+}
302+
303+// Err returns the runner's terminal error, if any.
304+func (a *Agent) Err() error {
305+ a.mu.Lock()
306+ defer a.mu.Unlock()
307+ return a.lastErr
308+}
309+
310+// Wait blocks until the agent reaches a terminal state. Used by tests
311+// and by /subagents wait <id>.
312+func (a *Agent) Wait() { <-a.done }
313+
314+// WaitContext waits for the agent to finish or for ctx cancellation.
315+func (a *Agent) WaitContext(ctx context.Context) error {
316+ if ctx == nil {
317+ ctx = context.Background()
318+ }
319+ select {
320+ case <-a.done:
321+ return nil
322+ case <-ctx.Done():
323+ return ctx.Err()
324+ }
325+}
326+
327+func (a *Agent) waitForTurnResult(ctx context.Context) (*TurnResult, error) {
328+ if ctx == nil {
329+ ctx = context.Background()
330+ }
331+ select {
332+ case result := <-a.turnResults:
333+ if result == nil {
334+ return nil, fmt.Errorf("subagents: nil turn result for %s", a.ID)
335+ }
336+ return cloneTurnResult(result), nil
337+ case <-a.done:
338+ if result := a.Result(); result != nil {
339+ return result, nil
340+ }
341+ return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID)
342+ case <-ctx.Done():
343+ return nil, ctx.Err()
344+ }
345+}
346+
347+func (a *Agent) closeDone() { a.doneOnce.Do(func() { close(a.done) }) }
348+
349+func (a *Agent) stateDirectory(defaultRoot string) string {
350+ if a.stateDir != "" {
351+ return a.stateDir
352+ }
353+ return filepath.Join(defaultRoot, "agents", a.ID)
354+}
355+
356+// SetOnTurnEnd installs (or clears, with nil) the callback fired
357+// from the runner when the child daemon emits a prompt-level
358+// turn_end event with a step field. Safe to call from any goroutine:
359+// the runner reads the callback under the same mutex.
360+func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
361+ a.mu.Lock()
362+ a.OnTurnEnd = fn
363+ pending := append([]turnEndNotice(nil), a.pendingTurnEnds...)
364+ a.pendingTurnEnds = nil
365+ a.mu.Unlock()
366+ if fn != nil {
367+ for _, notice := range pending {
368+ fn(notice.step, notice.errMsg)
369+ }
370+ }
371+}
372+
373+func (a *Agent) setStatus(s Status) {
374+ a.mu.Lock()
375+ a.status = s
376+ a.mu.Unlock()
377+ if s == StatusRunning {
378+ a.setProcessState(ProcessAlive)
379+ }
380+}
381+
382+func (a *Agent) setActivity(msg string) {
383+ a.mu.Lock()
384+ a.activity = strings.TrimSpace(msg)
385+ a.mu.Unlock()
386+ a.markActivity(time.Now())
387+}
388+
389+func (a *Agent) appendTranscript(chunk string) {
390+ a.appendTranscriptLocked(chunk, "", false)
391+}
392+
393+func (a *Agent) appendUserMessage(text string) {
394+ a.appendTranscriptLocked(text, "user: ", false)
395+}
396+
397+func (a *Agent) appendAssistantMessage(text string) {
398+ a.appendTranscriptLocked(text, "", true)
399+}
400+
401+func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) {
402+ message := chunk
403+ chunk = strings.TrimRight(chunk, "\n")
404+ if chunk == "" {
405+ return
406+ }
407+ a.mu.Lock()
408+ for _, line := range strings.Split(chunk, "\n") {
409+ line = linePrefix + line
410+ a.transcript = append(a.transcript, line)
411+ a.outputBytes += len(line) + 1
412+ a.outputLines++
413+ }
414+ if assistant {
415+ a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines)
416+ }
417+ // Bound the inline dashboard copy. The durable event log and session
418+ // remain available through the logical history reference.
419+ lineCap := a.maxOutputLines
420+ if lineCap <= 0 {
421+ lineCap = 2_000
422+ }
423+ byteCap := a.maxOutputBytes
424+ if byteCap <= 0 {
425+ byteCap = 500_000
426+ }
427+ for a.outputLines > lineCap {
428+ line := a.transcript[0]
429+ a.transcript = a.transcript[1:]
430+ a.outputBytes -= len(line) + 1
431+ a.outputLines--
432+ a.outputTruncated = true
433+ }
434+ for a.outputLines > 0 && a.outputBytes > byteCap {
435+ line := a.transcript[0]
436+ a.transcript = a.transcript[1:]
437+ a.outputBytes -= len(line) + 1
438+ a.outputLines--
439+ a.outputTruncated = true
440+ }
441+ if a.outputLines == 0 {
442+ a.outputBytes = 0
443+ }
444+ a.mu.Unlock()
445+ a.markActivity(time.Now())
446+}
447+
448+// newAgentID returns a short, mostly-collision-free identifier of the
449+// form "<slug>-<nano>". The slug is derived from the task text so
450+// dashboards stay readable; the nano suffix guarantees uniqueness
451+// even when two agents are spawned in the same millisecond.
452+func newAgentID(task string, now time.Time) string {
453+ slug := taskSlug(task)
454+ return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000)
455+}
456+
457+func taskSlug(task string) string {
458+ task = strings.ToLower(task)
459+ var b strings.Builder
460+ dash := false
461+ for _, r := range task {
462+ switch {
463+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
464+ b.WriteRune(r)
465+ dash = false
466+ default:
467+ if !dash && b.Len() > 0 {
468+ b.WriteByte('-')
469+ dash = true
470+ }
471+ }
472+ if b.Len() >= 24 {
473+ break
474+ }
475+ }
476+ out := strings.Trim(b.String(), "-")
477+ if out == "" {
478+ out = "agent"
479+ }
480+ return out
481+}
A packages/agent/subagents/agent_test.go +49 -0
@@ -0,0 +1,49 @@
1+package subagents
2+
3+import "testing"
4+
5+func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {
6+ a := &Agent{maxOutputBytes: 6, maxOutputLines: 10}
7+ if got := a.AttemptValue(); got != 0 {
8+ t.Fatalf("initial attempt = %d, want 0", got)
9+ }
10+ if got := a.incrementAttempt(); got != 1 || a.AttemptValue() != 1 {
11+ t.Fatalf("attempt after increment = %d, value = %d; want 1", got, a.AttemptValue())
12+ }
13+
14+ a.appendTranscript("a\nbb")
15+ if got := a.outputBytes; got != 5 {
16+ t.Fatalf("output bytes after append = %d, want 5", got)
17+ }
18+ if got := a.outputLines; got != 2 {
19+ t.Fatalf("output lines after append = %d, want 2", got)
20+ }
21+ a.appendTranscript("ccc")
22+ if got, want := a.Transcript(), []string{"ccc"}; len(got) != 1 || got[0] != want[0] {
23+ t.Fatalf("transcript after byte eviction = %q, want %q", got, want)
24+ }
25+ if a.outputBytes != 4 || a.outputLines != 1 {
26+ t.Fatalf("accounting after byte eviction = (%d bytes, %d lines), want (4, 1)", a.outputBytes, a.outputLines)
27+ }
28+
29+ a.maxOutputBytes = 1
30+ a.appendTranscript("z")
31+ if got := a.Transcript(); len(got) != 0 {
32+ t.Fatalf("transcript after evicting every line = %q, want empty", got)
33+ }
34+ if a.outputBytes != 0 || a.outputLines != 0 {
35+ t.Fatalf("empty accounting = (%d bytes, %d lines), want (0, 0)", a.outputBytes, a.outputLines)
36+ }
37+}
38+
39+func TestAgentTranscriptLineCapAccounting(t *testing.T) {
40+ a := &Agent{maxOutputBytes: 100, maxOutputLines: 2}
41+ a.appendTranscript("one\ntwo\nthree")
42+
43+ if got, want := a.Transcript(), []string{"two", "three"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
44+ t.Fatalf("transcript = %q, want %q", got, want)
45+ }
46+ if a.outputBytes != len("two")+1+len("three")+1 || a.outputLines != 2 {
47+ t.Fatalf("line-cap accounting = (%d bytes, %d lines), want (10, 2)", a.outputBytes, a.outputLines)
48+ }
49+}
A packages/agent/subagents/batch.go +429 -0
@@ -0,0 +1,429 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "sync"
13+ "time"
14+)
15+
16+// BatchRequest starts independent child tasks under one aggregate id. Batch
17+// scheduling is deliberately shallow; dependency graphs belong in workflows.
18+type BatchRequest struct {
19+ Context string
20+ Tasks []string
21+ MaxConcurrent int
22+ Spawn SpawnRequest
23+}
24+
25+type BatchStatus string
26+
27+const (
28+ BatchQueued BatchStatus = "queued"
29+ BatchRunning BatchStatus = "running"
30+ BatchSucceeded BatchStatus = "succeeded"
31+ BatchFailed BatchStatus = "failed"
32+ BatchCanceled BatchStatus = "canceled"
33+)
34+
35+type BatchResult struct {
36+ Version int `json:"version"`
37+ BatchID string `json:"batch_id"`
38+ TaskCount int `json:"task_count,omitempty"`
39+ Status BatchStatus `json:"status"`
40+ ChildIDs []string `json:"child_ids"`
41+ Results map[string]*TurnResult `json:"results,omitempty"`
42+ Error string `json:"error,omitempty"`
43+ CreatedAt time.Time `json:"created_at"`
44+ UpdatedAt time.Time `json:"updated_at"`
45+}
46+
47+const maxBatchFileBytes = 32 * 1024 * 1024
48+
49+type Batch struct {
50+ ID string
51+ Context string
52+ ChildIDs []string
53+ MaxConcurrent int
54+ TaskCount int
55+ CreatedAt time.Time
56+
57+ mu sync.Mutex
58+ status BatchStatus
59+ result *BatchResult
60+ done chan struct{}
61+ once sync.Once
62+}
63+
64+func (b *Batch) Status() BatchStatus {
65+ b.mu.Lock()
66+ defer b.mu.Unlock()
67+ return b.status
68+}
69+
70+func (b *Batch) Result() *BatchResult {
71+ b.mu.Lock()
72+ defer b.mu.Unlock()
73+ return cloneBatchResult(b.result)
74+}
75+
76+func (b *Batch) Wait() { <-b.done }
77+func (b *Batch) closeDone() { b.once.Do(func() { close(b.done) }) }
78+
79+func (f *Supervisor) SpawnBatch(ctx context.Context, req BatchRequest) (*Batch, error) {
80+ if len(req.Tasks) == 0 {
81+ return nil, fmt.Errorf("subagents: batch requires at least one task")
82+ }
83+ f.mu.Lock()
84+ remaining := f.cfg.Policy.MaxTotalSpawned - f.totalSpawned
85+ f.mu.Unlock()
86+ if len(req.Tasks) > remaining {
87+ return nil, fmt.Errorf("subagents: batch needs %d spawn budget entries, only %d remain", len(req.Tasks), remaining)
88+ }
89+ if ctx == nil {
90+ ctx = context.Background()
91+ }
92+ now := f.cfg.Now()
93+ baseID := newAgentID("batch "+now.Format("150405.000"), now)
94+ f.mu.Lock()
95+ if f.batches == nil {
96+ f.batches = make(map[string]*Batch)
97+ }
98+ batchID := baseID
99+ for suffix := 2; ; suffix++ {
100+ if _, exists := f.batches[batchID]; !exists {
101+ break
102+ }
103+ batchID = fmt.Sprintf("%s-%d", baseID, suffix)
104+ }
105+ batch := &Batch{
106+ ID: batchID,
107+ Context: strings.TrimSpace(req.Context),
108+ MaxConcurrent: req.MaxConcurrent,
109+ TaskCount: len(req.Tasks),
110+ CreatedAt: now,
111+ status: BatchQueued,
112+ result: &BatchResult{
113+ Version: ProtocolVersion, BatchID: batchID, TaskCount: len(req.Tasks), Status: BatchQueued,
114+ CreatedAt: now, UpdatedAt: now,
115+ },
116+ done: make(chan struct{}),
117+ }
118+ f.batches[batch.ID] = batch
119+ f.mu.Unlock()
120+ // Write a queued manifest before admitting children so a crash during
121+ // batch admission still leaves an aggregate id to reload and reconcile.
122+ f.persistBatch(batch)
123+
124+ for _, task := range req.Tasks {
125+ spawnReq := req.Spawn
126+ spawnReq.Task = task
127+ spawnReq.ParentID = batch.ID
128+ spawnReq.BatchID = batch.ID
129+ a, err := f.SpawnReq(ctx, spawnReq)
130+ if err != nil {
131+ childIDs := append([]string(nil), batch.ChildIDs...)
132+ for _, childID := range childIDs {
133+ _ = f.Stop(childID)
134+ }
135+ for _, childID := range childIDs {
136+ if child := f.Get(childID); child != nil {
137+ child.Wait()
138+ }
139+ }
140+ batch.mu.Lock()
141+ batch.status = BatchFailed
142+ batch.result = &BatchResult{
143+ Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, Status: BatchFailed,
144+ ChildIDs: childIDs, Error: err.Error(), CreatedAt: now, UpdatedAt: f.cfg.Now(),
145+ }
146+ batch.mu.Unlock()
147+ f.persistBatch(batch)
148+ batch.closeDone()
149+ return batch, err
150+ }
151+ batch.ChildIDs = append(batch.ChildIDs, a.ID)
152+ batch.mu.Lock()
153+ batch.result = &BatchResult{
154+ Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, Status: BatchQueued,
155+ ChildIDs: append([]string(nil), batch.ChildIDs...), CreatedAt: batch.CreatedAt,
156+ UpdatedAt: f.cfg.Now(),
157+ }
158+ batch.mu.Unlock()
159+ f.persistBatch(batch)
160+ }
161+ batch.mu.Lock()
162+ batch.status = BatchRunning
163+ batch.mu.Unlock()
164+ f.persistBatch(batch)
165+ go f.collectBatch(batch)
166+ return batch, nil
167+}
168+
169+func (f *Supervisor) collectBatch(batch *Batch) {
170+ if batch.TaskCount > 0 && len(batch.ChildIDs) < batch.TaskCount {
171+ batch.mu.Lock()
172+ batch.status = BatchFailed
173+ batch.result = &BatchResult{
174+ Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount,
175+ Status: BatchFailed, ChildIDs: append([]string(nil), batch.ChildIDs...),
176+ Error: "batch admission was incomplete", CreatedAt: batch.CreatedAt, UpdatedAt: f.cfg.Now(),
177+ }
178+ batch.mu.Unlock()
179+ f.persistBatch(batch)
180+ batch.closeDone()
181+ return
182+ }
183+ results := make(map[string]*TurnResult, len(batch.ChildIDs))
184+ status := BatchSucceeded
185+ var firstErr string
186+ for _, id := range batch.ChildIDs {
187+ a := f.Get(id)
188+ if a == nil {
189+ status = BatchFailed
190+ if firstErr == "" {
191+ firstErr = "missing child " + id
192+ }
193+ continue
194+ }
195+ result, err := f.waitForBatchResult(a)
196+ if err == nil {
197+ results[id] = result
198+ switch result.Status {
199+ case ResultFailed:
200+ if status == BatchSucceeded {
201+ status = BatchFailed
202+ if result.Error != nil {
203+ firstErr = result.Error.Message
204+ }
205+ }
206+ case ResultCanceled:
207+ if status == BatchSucceeded {
208+ status = BatchCanceled
209+ firstErr = "one or more batch children were canceled"
210+ }
211+ }
212+ } else {
213+ status = BatchFailed
214+ if firstErr == "" {
215+ firstErr = err.Error()
216+ }
217+ }
218+ }
219+ batch.mu.Lock()
220+ batch.status = status
221+ batch.result = &BatchResult{
222+ Version: ProtocolVersion, BatchID: batch.ID, TaskCount: batch.TaskCount, Status: status,
223+ ChildIDs: append([]string(nil), batch.ChildIDs...), Results: results,
224+ Error: firstErr, CreatedAt: batch.CreatedAt, UpdatedAt: f.cfg.Now(),
225+ }
226+ batch.mu.Unlock()
227+ f.persistBatch(batch)
228+ batch.closeDone()
229+}
230+
231+func (f *Supervisor) waitForBatchResult(a *Agent) (*TurnResult, error) {
232+ if a == nil {
233+ return nil, errors.New("subagents: missing batch child")
234+ }
235+ if result := a.Result(); result != nil {
236+ return result, nil
237+ }
238+ if a.Status() != StatusDetached {
239+ return a.waitForTurnResult(context.Background())
240+ }
241+
242+ // A reloaded worker has a closed supervisor-side done channel even when
243+ // its orphan process is still alive. Reconcile durable result.json/events
244+ // before treating that closed channel as a failed child.
245+ readDurable := func() *TurnResult {
246+ if result, err := readTurnResult(a.stateDirectory(f.cfg.Root)); err == nil && validateTurnResultAgent(result, a.ID) == nil {
247+ result = result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines)
248+ a.setResult(result)
249+ return result
250+ }
251+ events, err := ReadEventLog(a.EventLogPath)
252+ if err != nil {
253+ return nil
254+ }
255+ for _, event := range events {
256+ if event.Type != EventTurnResult && event.Type != "turn_result" {
257+ continue
258+ }
259+ result, decodeErr := decodeTurnResultEvent(event, a.ID, f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines)
260+ if decodeErr != nil {
261+ continue
262+ }
263+ a.setResult(result)
264+ return result
265+ }
266+ return nil
267+ }
268+
269+ if result := readDurable(); result != nil {
270+ return result, nil
271+ }
272+ if !inboxLive(a.InboxPath) {
273+ return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID)
274+ }
275+
276+ interval := 100 * time.Millisecond
277+ deadline := time.NewTimer(f.cfg.Policy.DefaultTimeout)
278+ defer deadline.Stop()
279+ ticker := time.NewTicker(interval)
280+ defer ticker.Stop()
281+ for {
282+ select {
283+ case <-ticker.C:
284+ if result := readDurable(); result != nil {
285+ return result, nil
286+ }
287+ if !inboxLive(a.InboxPath) {
288+ return nil, fmt.Errorf("subagents: agent %s exited without a turn result", a.ID)
289+ }
290+ case <-deadline.C:
291+ return nil, fmt.Errorf("subagents: agent %s batch result timed out", a.ID)
292+ }
293+ }
294+}
295+
296+func (f *Supervisor) WaitBatch(id string) (*BatchResult, error) {
297+ f.mu.Lock()
298+ batch := f.batches[id]
299+ f.mu.Unlock()
300+ if batch == nil {
301+ return nil, fmt.Errorf("subagents: no such batch %q", id)
302+ }
303+ batch.Wait()
304+ return batch.Result(), nil
305+}
306+
307+func (f *Supervisor) GetBatch(id string) *Batch {
308+ f.mu.Lock()
309+ defer f.mu.Unlock()
310+ return f.batches[id]
311+}
312+
313+func cloneBatchResult(result *BatchResult) *BatchResult {
314+ if result == nil {
315+ return nil
316+ }
317+ copy := *result
318+ copy.ChildIDs = append([]string(nil), result.ChildIDs...)
319+ if result.Results != nil {
320+ copy.Results = make(map[string]*TurnResult, len(result.Results))
321+ for id, child := range result.Results {
322+ copy.Results[id] = cloneTurnResult(child)
323+ }
324+ }
325+ return &copy
326+}
327+
328+func (f *Supervisor) reloadBatches(root string) []error {
329+ entries, err := os.ReadDir(filepath.Join(root, "batches"))
330+ if err != nil {
331+ if errors.Is(err, os.ErrNotExist) {
332+ return nil
333+ }
334+ return []error{fmt.Errorf("subagents reload batches %s: %w", root, err)}
335+ }
336+ var errs []error
337+ for _, entry := range entries {
338+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
339+ continue
340+ }
341+ fileID := strings.TrimSuffix(entry.Name(), ".json")
342+ if !safeAgentID(fileID) {
343+ errs = append(errs, fmt.Errorf("subagents reload batch %s: unsafe batch id", entry.Name()))
344+ continue
345+ }
346+ path := filepath.Join(root, "batches", entry.Name())
347+ file, openErr := os.Open(path)
348+ if openErr != nil {
349+ errs = append(errs, fmt.Errorf("subagents reload batch %s: %w", path, openErr))
350+ continue
351+ }
352+ data, readErr := io.ReadAll(io.LimitReader(file, maxBatchFileBytes+1))
353+ _ = file.Close()
354+ if readErr != nil {
355+ errs = append(errs, fmt.Errorf("subagents reload batch %s: %w", path, readErr))
356+ continue
357+ }
358+ if len(data) > maxBatchFileBytes {
359+ errs = append(errs, fmt.Errorf("subagents reload batch %s: exceeds %d bytes", path, maxBatchFileBytes))
360+ continue
361+ }
362+ var result BatchResult
363+ if unmarshalErr := json.Unmarshal(data, &result); unmarshalErr != nil {
364+ errs = append(errs, fmt.Errorf("subagents reload batch %s: %w", path, unmarshalErr))
365+ continue
366+ }
367+ if result.Version == 0 {
368+ result.Version = ProtocolVersion
369+ }
370+ if result.Version != ProtocolVersion || result.BatchID != fileID || !safeAgentID(result.BatchID) || result.TaskCount < 0 || len(result.ChildIDs) > result.TaskCount {
371+ errs = append(errs, fmt.Errorf("subagents reload batch %s: invalid result metadata", path))
372+ continue
373+ }
374+ batch := &Batch{
375+ ID: result.BatchID,
376+ ChildIDs: append([]string(nil), result.ChildIDs...),
377+ TaskCount: result.TaskCount,
378+ CreatedAt: result.CreatedAt,
379+ status: result.Status,
380+ result: cloneBatchResult(&result),
381+ done: make(chan struct{}),
382+ }
383+ if batch.CreatedAt.IsZero() {
384+ batch.CreatedAt = result.UpdatedAt
385+ }
386+ f.mu.Lock()
387+ _, exists := f.batches[batch.ID]
388+ if !exists {
389+ f.batches[batch.ID] = batch
390+ }
391+ f.mu.Unlock()
392+ if exists {
393+ continue
394+ }
395+ switch batch.status {
396+ case BatchSucceeded, BatchFailed, BatchCanceled:
397+ batch.closeDone()
398+ default:
399+ go f.collectBatch(batch)
400+ }
401+ }
402+ return errs
403+}
404+
405+func (f *Supervisor) persistBatch(batch *Batch) {
406+ if batch == nil || !safeAgentID(batch.ID) {
407+ return
408+ }
409+ result := batch.Result()
410+ if result == nil {
411+ return
412+ }
413+ data, err := json.MarshalIndent(result, "", " ")
414+ if err != nil || len(data) > maxBatchFileBytes {
415+ return
416+ }
417+ dir := filepath.Join(f.cfg.Root, "batches")
418+ if err := os.MkdirAll(dir, 0o700); err != nil {
419+ return
420+ }
421+ path := filepath.Join(dir, batch.ID+".json")
422+ tmp := path + ".tmp"
423+ if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
424+ return
425+ }
426+ if err := os.Rename(tmp, path); err != nil {
427+ _ = os.Remove(tmp)
428+ }
429+}
A packages/agent/subagents/batch_test.go +127 -0
@@ -0,0 +1,127 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+)
11+
12+func TestBatchIDsRemainUniqueWithFixedClock(t *testing.T) {
13+ now := time.Unix(123, 0)
14+ f := New(Config{
15+ Root: t.TempDir(), RepoRoot: t.TempDir(), Now: func() time.Time { return now },
16+ Policy: SubagentPolicy{MaxConcurrent: 2, MaxTotalSpawned: 4},
17+ NewRunner: func(*Agent) Runner {
18+ return RunnerFunc(func(context.Context, Sink) error { return nil })
19+ },
20+ })
21+ t.Cleanup(f.StopAll)
22+ first, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"one"}})
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ second, err := f.SpawnBatch(context.Background(), BatchRequest{Tasks: []string{"two"}})
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ first.Wait()
31+ second.Wait()
32+ if first.ID == second.ID {
33+ t.Fatalf("duplicate batch ids: %q", first.ID)
34+ }
35+}
36+
37+func TestBatchReloadsDurableResult(t *testing.T) {
38+ root := t.TempDir()
39+ newSupervisor := func() *Supervisor {
40+ return New(Config{
41+ Root: root, RepoRoot: root,
42+ NewRunner: func(*Agent) Runner {
43+ return RunnerFunc(func(context.Context, Sink) error { return nil })
44+ },
45+ })
46+ }
47+ first := newSupervisor()
48+ t.Cleanup(first.StopAll)
49+ batch, err := first.SpawnBatch(context.Background(), BatchRequest{Context: "review", Tasks: []string{"one", "two"}})
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ want, err := first.WaitBatch(batch.ID)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if want.Status != BatchSucceeded {
58+ t.Fatalf("first batch status = %s", want.Status)
59+ }
60+
61+ second := newSupervisor()
62+ t.Cleanup(second.StopAll)
63+ loaded, errs := second.Reload()
64+ if len(errs) != 0 {
65+ t.Fatalf("reload errors: %v", errs)
66+ }
67+ if loaded != 2 {
68+ t.Fatalf("reload loaded %d agents, want 2", loaded)
69+ }
70+ got, err := second.WaitBatch(batch.ID)
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ if got.Status != BatchSucceeded || len(got.Results) != 2 {
75+ t.Fatalf("reloaded batch = %#v", got)
76+ }
77+}
78+
79+func TestBatchReloadRejectsUnsafeIDs(t *testing.T) {
80+ root := t.TempDir()
81+ batchDir := filepath.Join(root, "batches")
82+ if err := os.MkdirAll(batchDir, 0o700); err != nil {
83+ t.Fatal(err)
84+ }
85+ data, err := json.Marshal(BatchResult{
86+ Version: ProtocolVersion, BatchID: "../escape", TaskCount: 0, Status: BatchRunning,
87+ })
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ if err := os.WriteFile(filepath.Join(batchDir, "queued.json"), data, 0o600); err != nil {
92+ t.Fatal(err)
93+ }
94+ f := New(Config{Root: root, RepoRoot: root})
95+ if _, errs := f.Reload(); len(errs) == 0 {
96+ t.Fatal("unsafe batch metadata was accepted")
97+ }
98+ if _, err := os.Stat(filepath.Join(root, "escape.json")); !os.IsNotExist(err) {
99+ t.Fatalf("unsafe batch path was written: %v", err)
100+ }
101+}
102+
103+func TestBatchSpawnWaitAndCollect(t *testing.T) {
104+ root := t.TempDir()
105+ f := New(Config{
106+ Root: root, RepoRoot: root,
107+ Policy: SubagentPolicy{MaxConcurrent: 2, MaxConcurrentPerParent: 2, MaxTotalSpawned: 4, DefaultTimeout: 0, IdleTimeout: 0},
108+ NewRunner: func(*Agent) Runner {
109+ return RunnerFunc(func(context.Context, Sink) error { return nil })
110+ },
111+ })
112+ t.Cleanup(f.StopAll)
113+ batch, err := f.SpawnBatch(context.Background(), BatchRequest{Context: "review", Tasks: []string{"one", "two"}, MaxConcurrent: 1})
114+ if err != nil {
115+ t.Fatal(err)
116+ }
117+ result, err := f.WaitBatch(batch.ID)
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ if result.Status != BatchSucceeded || len(result.ChildIDs) != 2 || len(result.Results) != 2 {
122+ t.Fatalf("batch result = %#v", result)
123+ }
124+ if batch.Status() != BatchSucceeded {
125+ t.Fatalf("batch status = %s", batch.Status())
126+ }
127+}
A packages/agent/subagents/event.go +441 -0
@@ -0,0 +1,441 @@
1+package subagents
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 subagent package doesn't have to track
19+// every field added to core.AgentEvent. The child writes
20+// `modes.EventToJSON(ev)` plus a few subagent-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+ Version int `json:"version,omitempty"`
34+ MessageID string `json:"message_id,omitempty"`
35+ AgentID string `json:"agent_id,omitempty"`
36+ TurnID string `json:"turn_id,omitempty"`
37+ Data map[string]any `json:"-"`
38+ Raw map[string]any `json:"-"` // original canonical event for replay
39+}
40+
41+// MarshalJSON writes the canonical versioned event shape used by the
42+// worker protocol and durable event logs.
43+func (e Event) MarshalJSON() ([]byte, error) {
44+ version := e.Version
45+ if version == 0 {
46+ version = ProtocolVersion
47+ }
48+ when := e.Time
49+ if when.IsZero() {
50+ when = time.Now().UTC()
51+ }
52+ payload := e.Data
53+ if payload == nil {
54+ payload = map[string]any{}
55+ }
56+ out := map[string]any{
57+ "version": version,
58+ "type": e.Type,
59+ "timestamp": when,
60+ "payload": payload,
61+ }
62+ if e.MessageID != "" {
63+ out["message_id"] = e.MessageID
64+ }
65+ if e.AgentID != "" {
66+ out["agent_id"] = e.AgentID
67+ }
68+ if e.TurnID != "" {
69+ out["turn_id"] = e.TurnID
70+ }
71+ return json.Marshal(out)
72+}
73+
74+// UnmarshalJSON accepts the canonical versioned event shape and retains the
75+// decoded payload for dashboard replay.
76+func (e *Event) UnmarshalJSON(b []byte) error {
77+ var wire struct {
78+ Version int `json:"version"`
79+ Type string `json:"type"`
80+ MessageID string `json:"message_id,omitempty"`
81+ AgentID string `json:"agent_id,omitempty"`
82+ TurnID string `json:"turn_id,omitempty"`
83+ Timestamp time.Time `json:"timestamp"`
84+ Payload map[string]any `json:"payload"`
85+ }
86+ if err := json.Unmarshal(b, &wire); err != nil {
87+ return err
88+ }
89+ if wire.Version != ProtocolVersion {
90+ return fmt.Errorf("subagent event: unsupported version %d", wire.Version)
91+ }
92+ if wire.Type == "" {
93+ return errors.New("subagent event: missing type")
94+ }
95+ if wire.Timestamp.IsZero() {
96+ return errors.New("subagent event: missing timestamp")
97+ }
98+ if wire.Payload == nil {
99+ wire.Payload = map[string]any{}
100+ }
101+ e.Raw = map[string]any{}
102+ if err := json.Unmarshal(b, &e.Raw); err != nil {
103+ return err
104+ }
105+ e.Time = wire.Timestamp
106+ e.Type = wire.Type
107+ e.Version = wire.Version
108+ e.MessageID = wire.MessageID
109+ e.AgentID = wire.AgentID
110+ e.TurnID = wire.TurnID
111+ e.Data = wire.Payload
112+ return nil
113+}
114+
115+const (
116+ // Event log lines are produced by a child process and may contain model
117+ // payloads. Bound both per-line decoding and replay memory so a corrupt or
118+ // hostile log cannot force an unbounded allocation during Reload.
119+ maxEventLogLineBytes = 2 * 1024 * 1024
120+ maxEventLogEvents = 100_000
121+)
122+
123+// EventLog is an append-only writer for events.jsonl. Safe for
124+// concurrent writers (the child only has one writer, but tests
125+// occasionally fan out).
126+type EventLog struct {
127+ path string
128+ mu sync.Mutex
129+ f *os.File
130+}
131+
132+// OpenEventLog opens (or creates) the events.jsonl file at path.
133+// Parent directories are created as needed.
134+func OpenEventLog(path string) (*EventLog, error) {
135+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
136+ return nil, fmt.Errorf("event log dir: %w", err)
137+ }
138+ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
139+ if err != nil {
140+ return nil, fmt.Errorf("event log open: %w", err)
141+ }
142+ _ = f.Chmod(0o600)
143+ return &EventLog{path: path, f: f}, nil
144+}
145+
146+// Path returns the absolute log path.
147+func (l *EventLog) Path() string { return l.path }
148+
149+// Append writes one event. The encoding is `<json>\n`. Concurrent
150+// callers are serialised; small enough events never need partial
151+// writes since unix guarantees atomicity for writes ≤ PIPE_BUF on
152+// regular files, and we hold a per-process mutex on top.
153+func (l *EventLog) Append(ev Event) error {
154+ l.mu.Lock()
155+ defer l.mu.Unlock()
156+ if l.f == nil {
157+ return errors.New("event log: closed")
158+ }
159+ if ev.Time.IsZero() {
160+ ev.Time = time.Now().UTC()
161+ }
162+ b, err := json.Marshal(ev)
163+ if err != nil {
164+ return err
165+ }
166+ if len(b) > maxEventLogLineBytes {
167+ return fmt.Errorf("event log: event exceeds %d bytes", maxEventLogLineBytes)
168+ }
169+ b = append(b, '\n')
170+ n, err := l.f.Write(b)
171+ if err == nil && n != len(b) {
172+ return io.ErrShortWrite
173+ }
174+ return err
175+}
176+
177+// Close flushes and closes the underlying file.
178+func (l *EventLog) Close() error {
179+ l.mu.Lock()
180+ defer l.mu.Unlock()
181+ if l.f == nil {
182+ return nil
183+ }
184+ err := l.f.Close()
185+ l.f = nil
186+ return err
187+}
188+
189+// NewEvent is a convenience constructor that fills the timestamp.
190+func NewEvent(typ string, data map[string]any) Event {
191+ if data == nil {
192+ data = map[string]any{}
193+ }
194+ return Event{Time: time.Now().UTC(), Type: typ, Version: ProtocolVersion, Data: data}
195+}
196+
197+// ReadEventLog parses every event currently in the file. Used by
198+// the dashboard on first open and by tests; live tailing uses
199+// FollowEventLog below.
200+func ReadEventLog(path string) ([]Event, error) {
201+ f, err := os.Open(path)
202+ if err != nil {
203+ if os.IsNotExist(err) {
204+ return nil, nil
205+ }
206+ return nil, err
207+ }
208+ defer f.Close()
209+ return readEvents(f)
210+}
211+
212+type eventRing struct {
213+ events []Event
214+ next int
215+ count int
216+}
217+
218+func newEventRing() eventRing {
219+ return eventRing{events: make([]Event, 0)}
220+}
221+
222+func (r *eventRing) append(ev Event) {
223+ if r.count < maxEventLogEvents {
224+ r.events = append(r.events, ev)
225+ r.count++
226+ if r.count == maxEventLogEvents {
227+ r.next = 0
228+ } else {
229+ r.next = r.count
230+ }
231+ return
232+ }
233+
234+ r.events[r.next] = ev
235+ r.next = (r.next + 1) % len(r.events)
236+}
237+
238+func (r *eventRing) last() (Event, bool) {
239+ if r.count == 0 {
240+ return Event{}, false
241+ }
242+ i := r.next - 1
243+ if i < 0 {
244+ i += len(r.events)
245+ }
246+ return r.events[i], true
247+}
248+
249+func (r *eventRing) isLikelyDoubleWrite(ev Event) bool {
250+ previous, ok := r.last()
251+ return ok && isLikelyDoubleWrite(ev, previous)
252+}
253+
254+func (r *eventRing) materialize() []Event {
255+ out := make([]Event, r.count)
256+ if r.count == 0 {
257+ return out
258+ }
259+ start := r.next - r.count
260+ if start < 0 {
261+ start += len(r.events)
262+ }
263+ n := copy(out, r.events[start:])
264+ copy(out[n:], r.events[:r.count-n])
265+ return out
266+}
267+
268+func readEvents(r io.Reader) ([]Event, error) {
269+ br := bufio.NewReader(r)
270+ ring := newEventRing()
271+ for {
272+ line, err, truncated := readBoundedLine(br, maxEventLogLineBytes)
273+ if len(line) > 0 && !truncated {
274+ var ev Event
275+ if jerr := json.Unmarshal(line, &ev); jerr == nil && !ring.isLikelyDoubleWrite(ev) {
276+ ring.append(ev)
277+ }
278+ // Malformed and oversized lines are skipped silently; the
279+ // dashboard renders only well-formed events and the child is
280+ // the only writer.
281+ }
282+ if err != nil {
283+ if errors.Is(err, io.EOF) {
284+ return ring.materialize(), nil
285+ }
286+ return ring.materialize(), err
287+ }
288+ }
289+}
290+
291+// readFollowerEvents parses only newline-terminated records from a live log.
292+// A follower must not advance past an incomplete final line: another process
293+// may be in the middle of appending that record, and skipping its start would
294+// make the completed event invisible on the next poll.
295+func readFollowerEvents(r io.Reader, offset int64) ([]Event, int64) {
296+ br := bufio.NewReader(r)
297+ ring := newEventRing()
298+ committed := offset
299+ for {
300+ line, err, truncated, consumed, complete := readBoundedLineStats(br, maxEventLogLineBytes)
301+ if !complete {
302+ break
303+ }
304+ committed += consumed
305+ if len(line) > 0 && !truncated {
306+ var ev Event
307+ if jerr := json.Unmarshal(line, &ev); jerr == nil && !ring.isLikelyDoubleWrite(ev) {
308+ ring.append(ev)
309+ }
310+ }
311+ if err != nil {
312+ break
313+ }
314+ }
315+ return ring.materialize(), committed
316+}
317+
318+// isLikelyDoubleWrite reports whether ev is a back-to-back duplicate
319+// of previous — same type and identical Data payload, with timestamps
320+// within a small window (the supervisor and the child's mirror used to
321+// BOTH write each event to disk, landing the same content twice in quick
322+// succession in events.jsonl). The historical behaviour was fixed at write
323+// time (the child's mirror is now dormant unless the supervisor is gone),
324+// but on-disk files from before the fix are still polluted, so we dedupe
325+// defensively at read time too.
326+//
327+// We deliberately bound by time (250ms) so two genuinely identical adjacent
328+// events that happen seconds apart — e.g. an agent that runs the same tool
329+// twice in a row — still both render.
330+func isLikelyDoubleWrite(ev, previous Event) bool {
331+ if previous.Type != ev.Type {
332+ return false
333+ }
334+ if !ev.Time.IsZero() && !previous.Time.IsZero() {
335+ dt := ev.Time.Sub(previous.Time)
336+ if dt < 0 {
337+ dt = -dt
338+ }
339+ if dt > 250*time.Millisecond {
340+ return false
341+ }
342+ }
343+ return sameEventData(previous.Data, ev.Data)
344+}
345+
346+// sameEventData deep-compares two event payloads. Cheap because the
347+// payloads are small map[string]any trees built from JSON, and only
348+// called for adjacent same-type pairs.
349+func sameEventData(a, b map[string]any) bool {
350+ if len(a) != len(b) {
351+ return false
352+ }
353+ ab, _ := json.Marshal(a)
354+ bb, _ := json.Marshal(b)
355+ return string(ab) == string(bb)
356+}
357+
358+// EventFollower polls an events.jsonl file and emits new events as
359+// they're appended. The supervisor uses one per agent so dashboard
360+// snapshots stay current without reparsing the whole file on every
361+// frame. Close stops the goroutine; the channel returned by Events
362+// is closed when Close is called or the file is removed.
363+type EventFollower struct {
364+ path string
365+ interval time.Duration
366+ out chan Event
367+ done chan struct{}
368+ closed chan struct{}
369+ once sync.Once
370+}
371+
372+// FollowEventLog starts polling path every interval. interval ≤ 0
373+// defaults to 50ms which is invisible to the user and cheap on
374+// disk (one stat + one short read per agent per tick).
375+func FollowEventLog(path string, interval time.Duration) *EventFollower {
376+ if interval <= 0 {
377+ interval = 50 * time.Millisecond
378+ }
379+ f := &EventFollower{
380+ path: path,
381+ interval: interval,
382+ out: make(chan Event, 256),
383+ done: make(chan struct{}),
384+ closed: make(chan struct{}),
385+ }
386+ go f.loop()
387+ return f
388+}
389+
390+// Events returns the receive end of the event stream.
391+func (f *EventFollower) Events() <-chan Event { return f.out }
392+
393+// Close stops polling and closes the events channel. It waits for
394+// the polling goroutine to exit so tests on Windows can remove the
395+// temp directory without racing an open file handle.
396+func (f *EventFollower) Close() {
397+ f.once.Do(func() {
398+ close(f.done)
399+ <-f.closed
400+ })
401+}
402+
403+func (f *EventFollower) loop() {
404+ defer close(f.closed)
405+ defer close(f.out)
406+ var offset int64
407+ tick := time.NewTicker(f.interval)
408+ defer tick.Stop()
409+ for {
410+ select {
411+ case <-f.done:
412+ return
413+ case <-tick.C:
414+ }
415+ fi, err := os.Stat(f.path)
416+ if err != nil {
417+ continue
418+ }
419+ if fi.Size() <= offset {
420+ continue
421+ }
422+ fh, err := os.Open(f.path)
423+ if err != nil {
424+ continue
425+ }
426+ if _, err := fh.Seek(offset, io.SeekStart); err != nil {
427+ _ = fh.Close()
428+ continue
429+ }
430+ evs, newOffset := readFollowerEvents(fh, offset)
431+ _ = fh.Close()
432+ offset = newOffset
433+ for _, ev := range evs {
434+ select {
435+ case f.out <- ev:
436+ case <-f.done:
437+ return
438+ }
439+ }
440+ }
441+}
R packages/agent/swarm/event_test.go → packages/agent/subagents/event_test.go +146 -2 renamed, 60% similar
@@ -1,14 +1,19 @@
1-package swarm
1+package subagents
22
33 import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "os"
48 "path/filepath"
9+ "strings"
510 "testing"
611 "time"
712 )
813
914 // TestEventLogAppendAndRead is the simplest possible invariant:
1015 // what we write goes back through ReadEventLog exactly, including
11-// the flat field shape and the timestamp.
16+// the payload fields and the timestamp.
1217 func TestEventLogAppendAndRead(t *testing.T) {
1318 path := filepath.Join(t.TempDir(), "events.jsonl")
1419 log, err := OpenEventLog(path)
@@ -43,11 +48,118 @@ func TestEventLogAppendAndRead(t *testing.T) {
4348 }
4449 }
4550
51+func TestEventLogAppendUsesUTCForZeroTimestamp(t *testing.T) {
52+ path := filepath.Join(t.TempDir(), "events.jsonl")
53+ log, err := OpenEventLog(path)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if err := log.Append(Event{Type: "zero_time", Version: ProtocolVersion, Data: map[string]any{}}); err != nil {
58+ t.Fatal(err)
59+ }
60+ if err := log.Close(); err != nil {
61+ t.Fatal(err)
62+ }
63+
64+ got, err := ReadEventLog(path)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if len(got) != 1 {
69+ t.Fatalf("got %d events, want 1", len(got))
70+ }
71+ if got[0].Time.Location() != time.UTC {
72+ t.Fatalf("zero timestamp location = %v, want UTC", got[0].Time.Location())
73+ }
74+}
75+
4676 // TestReadEventLogIgnoresGarbage documents that one malformed line
4777 // doesn't break the whole replay. The runner's responsibility is to
4878 // only write well-formed events, but a partial crash could leave
4979 // a half-written line; we don't want that to prevent the dashboard
5080 // from showing the rest.
81+func TestReadVersionedEventMergesPayloadFields(t *testing.T) {
82+ path := filepath.Join(t.TempDir(), "events.jsonl")
83+ wire := `{"version":1,"type":"turn.progress","message_id":"m1","agent_id":"a1","turn_id":"t1","timestamp":"2026-08-04T12:00:00Z","payload":{"text":"still working","future":{"step":2}}}` + "\n"
84+ if err := os.WriteFile(path, []byte(wire), 0o600); err != nil {
85+ t.Fatal(err)
86+ }
87+ got, err := ReadEventLog(path)
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ if len(got) != 1 || got[0].Data["text"] != "still working" {
92+ t.Fatalf("versioned payload was not replayed: %+v", got)
93+ }
94+ if future, ok := got[0].Data["future"].(map[string]any); !ok || future["step"] != float64(2) {
95+ t.Fatalf("future payload field was lost: %+v", got[0].Data)
96+ }
97+}
98+
99+func TestReadEventLogSkipsOversizedLines(t *testing.T) {
100+ path := filepath.Join(t.TempDir(), "events.jsonl")
101+ oversized := fmt.Sprintf("%s\n", strings.Repeat("x", maxEventLogLineBytes+100))
102+ valid := NewEvent("valid", map[string]any{"ok": true})
103+ data, err := json.Marshal(valid)
104+ if err != nil {
105+ t.Fatal(err)
106+ }
107+ if err := os.WriteFile(path, append([]byte(oversized), append(data, '\n')...), 0o600); err != nil {
108+ t.Fatal(err)
109+ }
110+ got, err := ReadEventLog(path)
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ if len(got) != 1 || got[0].Type != "valid" {
115+ t.Fatalf("oversized line affected replay: %+v", got)
116+ }
117+}
118+
119+func TestEventReadersRetainLatestEventsInOrder(t *testing.T) {
120+ total := maxEventLogEvents + 2
121+ var input bytes.Buffer
122+ for i := 0; i < total; i++ {
123+ ev := Event{
124+ Time: time.Unix(int64(i), 0).UTC(),
125+ Type: "indexed",
126+ Version: ProtocolVersion,
127+ Data: map[string]any{"index": i},
128+ }
129+ line, err := json.Marshal(ev)
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ input.Write(line)
134+ input.WriteByte('\n')
135+ }
136+
137+ check := func(name string, got []Event) {
138+ t.Helper()
139+ if len(got) != maxEventLogEvents {
140+ t.Fatalf("%s retained %d events, want %d", name, len(got), maxEventLogEvents)
141+ }
142+ for i, ev := range got {
143+ index, ok := ev.Data["index"].(float64)
144+ if !ok || int(index) != i+2 {
145+ t.Fatalf("%s event %d index = %v, want %d", name, i, ev.Data["index"], i+2)
146+ }
147+ }
148+ }
149+
150+ got, err := readEvents(bytes.NewReader(input.Bytes()))
151+ if err != nil {
152+ t.Fatal(err)
153+ }
154+ check("readEvents", got)
155+
156+ got, offset := readFollowerEvents(bytes.NewReader(input.Bytes()), 0)
157+ check("readFollowerEvents", got)
158+ if offset != int64(input.Len()) {
159+ t.Fatalf("follower offset = %d, want %d", offset, input.Len())
160+ }
161+}
162+
51163 func TestReadEventLogIgnoresGarbage(t *testing.T) {
52164 path := filepath.Join(t.TempDir(), "events.jsonl")
53165 log, err := OpenEventLog(path)
@@ -202,6 +314,38 @@ func TestFollowerEmitsNewEvents(t *testing.T) {
202314 }
203315 }
204316
317+func TestFollowerDoesNotSkipPartialFinalEvent(t *testing.T) {
318+ seed := NewEvent("preexisting", nil)
319+ seedBytes, err := json.Marshal(seed)
320+ if err != nil {
321+ t.Fatal(err)
322+ }
323+ seedBytes = append(seedBytes, '\n')
324+ partial := NewEvent("partial", map[string]any{"ok": true})
325+ partialBytes, err := json.Marshal(partial)
326+ if err != nil {
327+ t.Fatal(err)
328+ }
329+ mid := len(partialBytes) / 2
330+ incomplete := append(append([]byte(nil), seedBytes...), partialBytes[:mid]...)
331+ events, offset := readFollowerEvents(bytes.NewReader(incomplete), 0)
332+ if len(events) != 1 || events[0].Type != "preexisting" {
333+ t.Fatalf("complete prefix = %+v", events)
334+ }
335+ if offset != int64(len(seedBytes)) {
336+ t.Fatalf("offset = %d, want complete prefix %d", offset, len(seedBytes))
337+ }
338+
339+ complete := append(append([]byte(nil), seedBytes...), append(partialBytes, '\n')...)
340+ events, finalOffset := readFollowerEvents(bytes.NewReader(complete[offset:]), offset)
341+ if len(events) != 1 || events[0].Type != "partial" {
342+ t.Fatalf("completed event = %+v", events)
343+ }
344+ if finalOffset != int64(len(complete)) {
345+ t.Fatalf("final offset = %d, want %d", finalOffset, len(complete))
346+ }
347+}
348+
205349 func drain(ch <-chan Event) {
206350 for {
207351 select {
R packages/agent/swarm/fast_mode_test.go → packages/agent/subagents/fast_mode_test.go +10 -10 renamed, 88% similar
@@ -1,4 +1,4 @@
1-package swarm
1+package subagents
22
33 import (
44 "context"
@@ -6,7 +6,7 @@ import (
66 "testing"
77 )
88
9-func TestSwarmFastModePropagatesToChild(t *testing.T) {
9+func TestSupervisorFastModePropagatesToChild(t *testing.T) {
1010 root := t.TempDir()
1111 f := New(Config{
1212 Root: root,
@@ -103,9 +103,9 @@ func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) {
103103 }
104104 }
105105
106-func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) {
106+func TestSupervisorReloadPreservesExplicitFastModeOff(t *testing.T) {
107107 root := t.TempDir()
108- newSwarm := func(fastMode bool) *Swarm {
108+ newSupervisor := func(fastMode bool) *Supervisor {
109109 return New(Config{
110110 Root: root,
111111 RepoRoot: root,
@@ -119,7 +119,7 @@ func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) {
119119 })
120120 }
121121
122- f := newSwarm(false)
122+ f := newSupervisor(false)
123123 a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x"})
124124 if err != nil {
125125 t.Fatalf("spawn: %v", err)
@@ -132,7 +132,7 @@ func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) {
132132 }
133133 a.Wait()
134134
135- g := newSwarm(true)
135+ g := newSupervisor(true)
136136 if loaded, errs := g.Reload(); loaded != 1 || len(errs) != 0 {
137137 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
138138 }
@@ -141,15 +141,15 @@ func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) {
141141 }
142142 }
143143
144-func TestSwarmAgentArgsOmitFastModeWhenUnset(t *testing.T) {
145- args := swarmAgentArgs(swarmAgentArgsOpts{Exe: "/zot", Dir: "/wt", SessionPath: "/s", InboxPath: "/i"})
144+func TestSubagentWorkerArgsOmitFastModeWhenUnset(t *testing.T) {
145+ args := subagentWorkerArgs(subagentWorkerArgsOpts{Exe: "/zot", Dir: "/wt", SessionPath: "/s", InboxPath: "/i"})
146146 if strings.Contains(strings.Join(args, " "), "--fast-mode") || strings.Contains(strings.Join(args, " "), "--no-fast-mode") {
147147 t.Fatalf("child args = %v, want fast mode omitted when unset", args)
148148 }
149149 }
150150
151-func TestSwarmAgentArgsExplicitlyDisablesFastMode(t *testing.T) {
152- args := swarmAgentArgs(swarmAgentArgsOpts{
151+func TestSubagentWorkerArgsExplicitlyDisablesFastMode(t *testing.T) {
152+ args := subagentWorkerArgs(subagentWorkerArgsOpts{
153153 Exe: "/zot",
154154 Dir: "/wt",
155155 SessionPath: "/s",
R packages/agent/swarm/inbox.go → packages/agent/subagents/inbox.go +126 -74 renamed, 57% similar
@@ -1,10 +1,9 @@
1-package swarm
1+package subagents
22
33 import (
44 "bufio"
55 "errors"
66 "fmt"
7- "io"
87 "net"
98 "os"
109 "path/filepath"
@@ -13,28 +12,16 @@ import (
1312 "time"
1413 )
1514
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.
15+// Inbox is the supervisor-side handle on a per-agent unix socket.
16+// The wire format is newline-delimited, versioned JSON commands. The child
17+// listens on the same socket via Listener (below); the parent never reads
18+// from it.
3219 //
3320 // Why a socket and not a FIFO: FIFOs are POSIX-only and have
3421 // awkward blocking-open semantics. A unix socket gives us:
3522 //
3623 // - the ability to fail fast if no child is listening yet,
37-// so SendInput returns a useful error to the TUI;
24+// so the supervisor gets a useful error to surface in the TUI;
3825 // - clean shutdown semantics when the child closes the
3926 // listener;
4027 // - works under tests with a temp dir; and
@@ -48,7 +35,7 @@ type Inbox struct {
4835 }
4936
5037 // NewInbox returns a handle that will dial the socket at path on
51-// the first SendInput call. The socket file is expected to be
38+// the first SendCommand call. The socket file is expected to be
5239 // created by the child (Listener.Open below). This split lets the
5340 // parent build the Inbox before the child has booted; sends that
5441 // happen before the child is ready get a clear "not yet" error
@@ -59,13 +46,16 @@ func NewInbox(path string) *Inbox { return &Inbox{path: path} }
5946 // pass the path to the child as a flag.
6047 func (b *Inbox) Path() string { return b.path }
6148
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 {
49+// SendCommand sends a versioned JSONL command.
50+func (b *Inbox) SendCommand(command Envelope) error {
51+ line, err := MarshalJSONL(command)
52+ if err != nil {
53+ return err
54+ }
55+ return b.sendBytes(line)
56+}
57+
58+func (b *Inbox) sendBytes(data []byte) error {
6959 b.mu.Lock()
7060 defer b.mu.Unlock()
7161 if b.conn == nil {
@@ -75,7 +65,7 @@ func (b *Inbox) SendInput(msg string) error {
7565 }
7666 b.conn = c
7767 }
78- if _, err := io.WriteString(b.conn, msg+"\n"); err != nil {
68+ if _, err := b.conn.Write(data); err != nil {
7969 // Drop the connection so the next call redials. The
8070 // previous error is more informative than the redial's
8171 // would be, so surface this one.
@@ -100,10 +90,10 @@ func (b *Inbox) Close() error {
10090 return nil
10191 }
10292
103-// ErrNotReady is returned by SendInput when the child hasn't yet
93+// ErrNotReady is returned when the child hasn't yet
10494 // opened its listener. Callers can retry after a short backoff;
10595 // the TUI surfaces it as "agent <id> not listening yet".
106-var ErrNotReady = errors.New("swarm: agent inbox not ready")
96+var ErrNotReady = errors.New("subagent: agent inbox not ready")
10797
10898 // dialUnix wraps net.DialTimeout with a small retry window so the
10999 // happy path "child just started, supervisor sends right away"
@@ -113,7 +103,7 @@ var ErrNotReady = errors.New("swarm: agent inbox not ready")
113103 // one is listening": either the socket file doesn't exist (child
114104 // hasn't booted) or it does exist but the dial was refused (the
115105 // previous child exited and left a stale inode). Both are
116-// recoverable by Swarm.Resume, and both should surface as the same
106+// recoverable by Supervisor.Resume, and both should surface as the same
117107 // user-facing hint rather than as a path-leaking error string.
118108 func dialUnix(path string, timeout time.Duration) (net.Conn, error) {
119109 deadline := time.Now().Add(timeout)
@@ -135,7 +125,7 @@ func dialUnix(path string, timeout time.Duration) (net.Conn, error) {
135125 if isNoListenerErr(lastErr) {
136126 return nil, ErrNotReady
137127 }
138- return nil, fmt.Errorf("swarm: dial %s: %w", path, lastErr)
128+ return nil, fmt.Errorf("subagent: dial %s: %w", path, lastErr)
139129 }
140130
141131 // isNoListenerErr reports whether err means "the socket file exists
@@ -147,7 +137,31 @@ func isNoListenerErr(err error) bool {
147137 return errors.Is(err, syscall.ECONNREFUSED)
148138 }
149139
150-// Listener is the child-side end of the inbox. The swarm-agent
140+// inboxLive reports whether a process currently accepts connections on path.
141+// A Unix socket inode can survive its owner, so os.Stat alone cannot
142+// distinguish a live worker from a stale path. Callers use this probe before
143+// resume/remove operations to avoid racing a detached worker's session and
144+// workspace ownership.
145+const inboxProbeLine = "__zot_subagent_probe__"
146+
147+func inboxLive(path string) bool {
148+ if path == "" {
149+ return false
150+ }
151+ conn, err := net.DialTimeout("unix", path, 100*time.Millisecond)
152+ if err != nil {
153+ return false
154+ }
155+ // Listener treats this first line as a health probe and does not
156+ // replace the current supervisor connection. A bare connect would be
157+ // enough to detect liveness, but sending an explicit probe lets the
158+ // listener distinguish this side-effect-free check from a takeover.
159+ _, writeErr := conn.Write([]byte(inboxProbeLine + "\n"))
160+ _ = conn.Close()
161+ return writeErr == nil
162+}
163+
164+// Listener is the child-side end of the inbox. The subagent-worker
151165 // daemon mode constructs one with Listen, then iterates Lines
152166 // to receive supervisor messages. Designed so a single goroutine
153167 // in the child can drive prompting without juggling raw net code.
@@ -158,35 +172,53 @@ type Listener struct {
158172 // only expects one supervisor (the parent zot), so newer
159173 // connections preempt older ones — the previous parent
160174 // presumably crashed.
161- mu sync.Mutex
162- active net.Conn
163- out chan string
164- done chan struct{}
165- wg sync.WaitGroup
175+ mu sync.Mutex
176+ active net.Conn
177+ conns map[net.Conn]struct{}
178+ out chan string
179+ done chan struct{}
180+ wg sync.WaitGroup
181+ closed bool
182+ closeOnce sync.Once
166183 }
167184
168185 // Listen creates the socket at path and starts accepting. The
169186 // caller must call Close to remove the socket file on shutdown.
170-// Returns the listener even if a stale socket exists from a
171-// previous run: we unlink first, mirroring how most unix daemons
172-// behave.
187+// A live endpoint is preserved and rejected; only a refused probe
188+// is treated as a stale socket eligible for removal.
173189 func Listen(path string) (*Listener, error) {
174- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
190+ dir := filepath.Dir(path)
191+ if err := os.MkdirAll(dir, 0o700); err != nil {
175192 return nil, fmt.Errorf("inbox dir: %w", err)
176193 }
177- // Best-effort cleanup of a stale socket. If the parent
178- // process is still alive and using it, Listen will fail
179- // below and the caller surfaces that as a real conflict.
180- _ = os.Remove(path)
194+ if err := os.Chmod(dir, 0o700); err != nil {
195+ return nil, fmt.Errorf("inbox dir permissions: %w", err)
196+ }
197+ // Probe an existing endpoint before removing it. Unlinking an active
198+ // Unix socket does not stop its listener, so blindly removing the path
199+ // could let Resume start a second worker on the same session and event log.
200+ if _, statErr := os.Stat(path); statErr == nil {
201+ if inboxLive(path) {
202+ return nil, fmt.Errorf("subagent inbox already has a live listener")
203+ }
204+ // A refused probe means the socket inode is stale. Remove only that
205+ // endpoint; a live listener was handled above without unlinking it.
206+ _ = os.Remove(path)
207+ }
181208 ln, err := net.Listen("unix", path)
182209 if err != nil {
183210 return nil, fmt.Errorf("inbox listen: %w", err)
184211 }
212+ // Unix sockets inherit the process umask but may otherwise be
213+ // world-writable. The inbox carries prompts and control commands,
214+ // so fail closed for other local users.
215+ _ = os.Chmod(path, 0o600)
185216 l := &Listener{
186- path: path,
187- ln: ln,
188- out: make(chan string, 16),
189- done: make(chan struct{}),
217+ path: path,
218+ ln: ln,
219+ conns: make(map[net.Conn]struct{}),
220+ out: make(chan string, 16),
221+ done: make(chan struct{}),
190222 }
191223 go l.acceptLoop()
192224 return l, nil
@@ -199,19 +231,17 @@ func (l *Listener) Lines() <-chan string { return l.out }
199231 // Close stops accepting, drops the active connection, removes
200232 // the socket file, and closes Lines. Idempotent.
201233 func (l *Listener) Close() error {
202- select {
203- case <-l.done:
204- return nil
205- default:
234+ l.closeOnce.Do(func() {
235+ l.mu.Lock()
236+ l.closed = true
206237 close(l.done)
207- }
208- l.mu.Lock()
209- if l.active != nil {
210- _ = l.active.Close()
211- }
212- l.mu.Unlock()
213- _ = l.ln.Close()
214- _ = os.Remove(l.path)
238+ for conn := range l.conns {
239+ _ = conn.Close()
240+ }
241+ l.mu.Unlock()
242+ _ = l.ln.Close()
243+ _ = os.Remove(l.path)
244+ })
215245 return nil
216246 }
217247
@@ -233,12 +263,14 @@ func (l *Listener) acceptLoop() {
233263 }
234264 }
235265 l.mu.Lock()
236- if l.active != nil {
237- _ = l.active.Close()
266+ if l.closed {
267+ l.mu.Unlock()
268+ _ = c.Close()
269+ return
238270 }
239- l.active = c
240- l.mu.Unlock()
271+ l.conns[c] = struct{}{}
241272 l.wg.Add(1)
273+ l.mu.Unlock()
242274 go func() {
243275 defer l.wg.Done()
244276 l.readLoop(c)
@@ -247,24 +279,44 @@ func (l *Listener) acceptLoop() {
247279 }
248280
249281 func (l *Listener) readLoop(c net.Conn) {
282+ defer func() {
283+ _ = c.Close()
284+ l.mu.Lock()
285+ delete(l.conns, c)
286+ if l.active == c {
287+ l.active = nil
288+ }
289+ l.mu.Unlock()
290+ }()
291+
250292 br := bufio.NewReader(c)
293+ firstLine := true
251294 for {
252295 line, err := br.ReadString('\n')
253296 if line != "" {
297+ message := trimRightNL(line)
298+ if firstLine {
299+ firstLine = false
300+ if message == inboxProbeLine {
301+ return
302+ }
303+ // The first real command claims ownership. Health probes do
304+ // not preempt the current supervisor, while a replacement
305+ // supervisor still gets the historical takeover behavior.
306+ l.mu.Lock()
307+ if l.active != nil && l.active != c {
308+ _ = l.active.Close()
309+ }
310+ l.active = c
311+ l.mu.Unlock()
312+ }
254313 select {
255- case l.out <- trimRightNL(line):
314+ case l.out <- message:
256315 case <-l.done:
257- _ = c.Close()
258316 return
259317 }
260318 }
261319 if err != nil {
262- _ = c.Close()
263- l.mu.Lock()
264- if l.active == c {
265- l.active = nil
266- }
267- l.mu.Unlock()
268320 return
269321 }
270322 }
R packages/agent/swarm/inbox_test.go → packages/agent/subagents/inbox_test.go +33 -19 renamed, 79% similar
@@ -1,4 +1,4 @@
1-package swarm
1+package subagents
22
33 import (
44 "errors"
@@ -20,7 +20,7 @@ import (
2020 func shortSocketDir(t *testing.T) string {
2121 t.Helper()
2222 if runtime.GOOS == "windows" {
23- t.Skip("swarm inbox transport uses Unix-domain sockets; Windows support will use a named-pipe backend")
23+ t.Skip("subagent inbox transport uses Unix-domain sockets; Windows support will use a named-pipe backend")
2424 }
2525 dir, err := os.MkdirTemp("/tmp", "zot-in-")
2626 if err != nil {
@@ -41,26 +41,40 @@ func TestInboxRoundTrip(t *testing.T) {
4141 }
4242 defer ln.Close()
4343
44- got := collectFor(t, ln.Lines(), 3, time.Second)
44+ got := collectFor(t, ln.Lines(), 4, time.Second)
4545 inbox := NewInbox(path)
4646 defer inbox.Close()
4747
4848 want := []string{"user hello", "user world", "cancel"}
4949 for _, msg := range want {
50- if err := inbox.SendInput(msg); err != nil {
51- t.Fatalf("SendInput(%q): %v", msg, err)
50+ if err := inbox.sendBytes([]byte(msg + "\n")); err != nil {
51+ t.Fatalf("sendBytes(%q): %v", msg, err)
5252 }
5353 }
54+ if err := inbox.SendCommand(NewCommand(CommandTurnStart, "agent-1", "turn-1", TurnStartPayload{Prompt: "round trip"})); err != nil {
55+ t.Fatalf("SendCommand: %v", err)
56+ }
5457
5558 gotLines := <-got
56- if len(gotLines) != len(want) {
57- t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want), gotLines)
59+ if len(gotLines) != len(want)+1 {
60+ t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want)+1, gotLines)
5861 }
5962 for i, w := range want {
6063 if gotLines[i] != w {
6164 t.Errorf("line %d = %q; want %q", i, gotLines[i], w)
6265 }
6366 }
67+ parsed, err := ParseCommand(gotLines[len(want)])
68+ if err != nil {
69+ t.Fatalf("ParseCommand: %v", err)
70+ }
71+ var payload TurnStartPayload
72+ if err := parsed.DecodePayload(&payload); err != nil {
73+ t.Fatalf("DecodePayload: %v", err)
74+ }
75+ if payload.Prompt != "round trip" {
76+ t.Fatalf("decoded prompt = %q, want %q", payload.Prompt, "round trip")
77+ }
6478 }
6579
6680 // TestInboxNotReady regression-tests the explicit "child hasn't
@@ -70,19 +84,19 @@ func TestInboxNotReady(t *testing.T) {
7084 path := filepath.Join(shortSocketDir(t), "in.sock")
7185 inbox := NewInbox(path)
7286 defer inbox.Close()
73- err := inbox.SendInput("user hi")
87+ err := inbox.sendBytes([]byte("user hi\n"))
7488 if err == nil {
75- t.Fatal("SendInput on missing socket: want error")
89+ t.Fatal("sendBytes on missing socket: want error")
7690 }
7791 if !errors.Is(err, ErrNotReady) {
78- t.Fatalf("SendInput err = %v; want ErrNotReady", err)
92+ t.Fatalf("sendBytes err = %v; want ErrNotReady", err)
7993 }
8094 }
8195
8296 // TestIsNoListenerErrMatchesECONNREFUSED documents the contract
8397 // that the user's screenshot regression depends on: any dial error
8498 // whose root cause is "the kernel refused the connect because nobody
85-// is listening" must fold into ErrNotReady at the SendInput layer,
99+// is listening" must fold into ErrNotReady at the sendBytes layer,
86100 // so the TUI shows "agent not accepting input (press R to resume)"
87101 // instead of leaking the unix-socket path. We pattern-match via
88102 // errors.Is on syscall.ECONNREFUSED rather than string-matching the
@@ -104,7 +118,7 @@ func TestIsNoListenerErrMatchesECONNREFUSED(t *testing.T) {
104118 }
105119
106120 // TestInboxNewSupervisorPreemptsOld documents that only one
107-// supervisor connection at a time is honoured. The swarm design
121+// supervisor connection at a time is honoured. The subagent design
108122 // assumes a single parent zot owns each agent; if a second
109123 // parent dials, the listener boots the first one. The first
110124 // supervisor's already-delivered messages still land in the
@@ -119,7 +133,7 @@ func TestInboxNewSupervisorPreemptsOld(t *testing.T) {
119133 defer ln.Close()
120134
121135 first := NewInbox(path)
122- if err := first.SendInput("user one"); err != nil {
136+ if err := first.sendBytes([]byte("user one\n")); err != nil {
123137 t.Fatalf("first send: %v", err)
124138 }
125139 // Wait for the listener to absorb "user one" before dialing the
@@ -130,7 +144,7 @@ func TestInboxNewSupervisorPreemptsOld(t *testing.T) {
130144
131145 second := NewInbox(path)
132146 defer second.Close()
133- if err := second.SendInput("user two"); err != nil {
147+ if err := second.sendBytes([]byte("user two\n")); err != nil {
134148 t.Fatalf("second send: %v", err)
135149 }
136150
@@ -140,17 +154,17 @@ func TestInboxNewSupervisorPreemptsOld(t *testing.T) {
140154 }
141155
142156 // The original supervisor's connection was preempted by the
143- // listener accepting the second dial. The next SendInput on the
157+ // listener accepting the second dial. The next sendBytes on the
144158 // first handle sees a broken pipe and drops the cached connection;
145159 // the call after that redials successfully. Both behaviours
146160 // matter: the broken-pipe error must surface (so a real
147161 // supervisor knows it lost ownership) AND the next call must
148162 // recover so a flaky reconnect doesn't permanently wedge the
149163 // inbox.
150- if err := first.SendInput("user three"); err == nil {
164+ if err := first.sendBytes([]byte("user three\n")); err == nil {
151165 t.Fatal("expected broken-pipe error on stale connection")
152166 }
153- if err := first.SendInput("user four"); err != nil {
167+ if err := first.sendBytes([]byte("user four\n")); err != nil {
154168 t.Fatalf("first redial after eviction: %v", err)
155169 }
156170 }
@@ -197,7 +211,7 @@ func collectFor(t *testing.T, ch <-chan string, n int, timeout time.Duration) <-
197211 return out
198212 }
199213
200-// TestInboxConcurrentSends serialises the SendInput call so two
214+// TestInboxConcurrentSends serialises the sendBytes call so two
201215 // goroutines writing at once still produce two whole lines, never
202216 // a tearing of bytes between them.
203217 func TestInboxConcurrentSends(t *testing.T) {
@@ -216,7 +230,7 @@ func TestInboxConcurrentSends(t *testing.T) {
216230 for i := 0; i < n; i++ {
217231 go func(i int) {
218232 defer wg.Done()
219- if err := inbox.SendInput("user msg"); err != nil {
233+ if err := inbox.sendBytes([]byte("user msg\n")); err != nil {
220234 t.Errorf("send %d: %v", i, err)
221235 }
222236 }(i)
A packages/agent/subagents/lease.go +30 -0
@@ -0,0 +1,30 @@
1+package subagents
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+)
9+
10+// ErrAgentLeaseHeld means another supervisor currently owns an agent's
11+// durable state. The lease is advisory; socket probing remains the fence for
12+// workers that are still live.
13+var ErrAgentLeaseHeld = errors.New("subagents: agent state is owned by another supervisor")
14+
15+type agentLease struct {
16+ file *os.File
17+}
18+
19+func openAgentLeaseFile(stateDir string) (*os.File, error) {
20+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
21+ return nil, fmt.Errorf("subagents lease directory: %w", err)
22+ }
23+ path := filepath.Join(stateDir, "owner.lock")
24+ file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
25+ if err != nil {
26+ return nil, fmt.Errorf("subagents lease open: %w", err)
27+ }
28+ _ = file.Chmod(0o600)
29+ return file, nil
30+}
A packages/agent/subagents/lease_test.go +32 -0
@@ -0,0 +1,32 @@
1+package subagents
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestAgentLeaseIsExclusive(t *testing.T) {
9+ stateDir := t.TempDir()
10+ first, err := acquireAgentLease(stateDir)
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ defer first.Close()
15+ second, err := acquireAgentLease(stateDir)
16+ if !errors.Is(err, ErrAgentLeaseHeld) {
17+ if second != nil {
18+ _ = second.Close()
19+ }
20+ t.Fatalf("second lease = %v, want ErrAgentLeaseHeld", err)
21+ }
22+ if err := first.Close(); err != nil {
23+ t.Fatal(err)
24+ }
25+ third, err := acquireAgentLease(stateDir)
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ if err := third.Close(); err != nil {
30+ t.Fatal(err)
31+ }
32+}
A packages/agent/subagents/lease_unix.go +36 -0
@@ -0,0 +1,36 @@
1+//go:build !windows
2+
3+package subagents
4+
5+import (
6+ "errors"
7+ "syscall"
8+)
9+
10+func acquireAgentLease(stateDir string) (*agentLease, error) {
11+ file, err := openAgentLeaseFile(stateDir)
12+ if err != nil {
13+ return nil, err
14+ }
15+ if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
16+ _ = file.Close()
17+ if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
18+ return nil, ErrAgentLeaseHeld
19+ }
20+ return nil, err
21+ }
22+ return &agentLease{file: file}, nil
23+}
24+
25+func (l *agentLease) Close() error {
26+ if l == nil || l.file == nil {
27+ return nil
28+ }
29+ unlockErr := syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
30+ closeErr := l.file.Close()
31+ l.file = nil
32+ if unlockErr != nil {
33+ return unlockErr
34+ }
35+ return closeErr
36+}
A packages/agent/subagents/lease_windows.go +40 -0
@@ -0,0 +1,40 @@
1+//go:build windows
2+
3+package subagents
4+
5+import (
6+ "errors"
7+
8+ "golang.org/x/sys/windows"
9+)
10+
11+func acquireAgentLease(stateDir string) (*agentLease, error) {
12+ file, err := openAgentLeaseFile(stateDir)
13+ if err != nil {
14+ return nil, err
15+ }
16+ flags := uint32(windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY)
17+ var overlapped windows.Overlapped
18+ if err := windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &overlapped); err != nil {
19+ _ = file.Close()
20+ if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
21+ return nil, ErrAgentLeaseHeld
22+ }
23+ return nil, err
24+ }
25+ return &agentLease{file: file}, nil
26+}
27+
28+func (l *agentLease) Close() error {
29+ if l == nil || l.file == nil {
30+ return nil
31+ }
32+ var overlapped windows.Overlapped
33+ unlockErr := windows.UnlockFileEx(windows.Handle(l.file.Fd()), 0, 1, 0, &overlapped)
34+ closeErr := l.file.Close()
35+ l.file = nil
36+ if unlockErr != nil {
37+ return unlockErr
38+ }
39+ return closeErr
40+}
A packages/agent/subagents/model.go +119 -0
@@ -0,0 +1,119 @@
1+package subagents
2+
3+import "time"
4+
5+// ProcessState describes the lifetime of the supervised child process. It is
6+// intentionally independent from TurnState: an alive worker can be idle
7+// between turns, and a disconnected supervisor does not imply that the
8+// worker's current turn failed.
9+type ProcessState string
10+
11+const (
12+ ProcessPending ProcessState = "pending"
13+ ProcessStarting ProcessState = "starting"
14+ ProcessAlive ProcessState = "alive"
15+ ProcessDetached ProcessState = "detached"
16+ ProcessExited ProcessState = "exited"
17+ ProcessKilled ProcessState = "killed"
18+)
19+
20+// TurnState describes one delegated prompt inside a worker process.
21+type TurnState string
22+
23+const (
24+ TurnIdle TurnState = "idle"
25+ TurnQueued TurnState = "queued"
26+ TurnRunning TurnState = "running"
27+ TurnCanceling TurnState = "canceling"
28+ TurnSucceeded TurnState = "succeeded"
29+ TurnFailed TurnState = "failed"
30+ TurnCanceled TurnState = "canceled"
31+)
32+
33+// WorkspaceMode selects how a child is allowed to access the repository.
34+type WorkspaceMode string
35+
36+const (
37+ WorkspaceShared WorkspaceMode = "shared"
38+ WorkspaceWorktree WorkspaceMode = "worktree"
39+)
40+
41+// CaptureMode controls which durable worktree output is collected.
42+type CaptureMode string
43+
44+const (
45+ CapturePatch CaptureMode = "patch"
46+ CaptureDiff CaptureMode = "diff"
47+)
48+
49+// SubagentPolicy contains manager-owned safety and resource limits. normalize
50+// replaces zero MaxOutputBytes, MaxOutputLines, QueueTimeout, and
51+// DefaultTimeout values with positive manager defaults. Other zero values retain
52+// their documented semantics.
53+type SubagentPolicy struct {
54+ MaxConcurrent int
55+ MaxConcurrentPerParent int
56+ MaxTotalSpawned int
57+ QueueTimeout time.Duration
58+ DefaultTimeout time.Duration
59+ MaxTurns int
60+ MaxOutputBytes int
61+ MaxOutputLines int
62+ AllowedTools []string
63+ AllowedRoots []string
64+ HeartbeatInterval time.Duration
65+ IdleTimeout time.Duration
66+ ReconnectTimeout time.Duration
67+ CancelGracePeriod time.Duration
68+}
69+
70+func (p *SubagentPolicy) normalize() {
71+ if p.MaxConcurrent <= 0 {
72+ p.MaxConcurrent = 8
73+ }
74+ if p.MaxConcurrentPerParent <= 0 {
75+ p.MaxConcurrentPerParent = 4
76+ }
77+ if p.MaxTotalSpawned <= 0 {
78+ p.MaxTotalSpawned = 32
79+ }
80+ if p.QueueTimeout <= 0 {
81+ p.QueueTimeout = 30 * time.Minute
82+ }
83+ if p.DefaultTimeout <= 0 {
84+ p.DefaultTimeout = 20 * time.Minute
85+ }
86+ if p.MaxTurns <= 0 {
87+ p.MaxTurns = 3
88+ }
89+ if p.MaxOutputBytes <= 0 {
90+ p.MaxOutputBytes = 500_000
91+ }
92+ if p.MaxOutputLines <= 0 {
93+ p.MaxOutputLines = 5_000
94+ }
95+ if p.HeartbeatInterval <= 0 {
96+ p.HeartbeatInterval = 10 * time.Second
97+ }
98+ if p.IdleTimeout <= 0 {
99+ p.IdleTimeout = 7 * time.Minute
100+ }
101+ if p.ReconnectTimeout <= 0 {
102+ p.ReconnectTimeout = 5 * time.Second
103+ }
104+ if p.CancelGracePeriod <= 0 {
105+ p.CancelGracePeriod = 10 * time.Second
106+ }
107+}
108+
109+func (p SubagentPolicy) allowedTool(name string) bool {
110+ if len(p.AllowedTools) == 0 {
111+ return true
112+ }
113+ for _, allowed := range p.AllowedTools {
114+ if allowed == name {
115+ return true
116+ }
117+ }
118+ return false
119+}
A packages/agent/subagents/persist.go +796 -0
@@ -0,0 +1,796 @@
1+package subagents
2+
3+// On-disk persistence for subagent 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, Supervisor.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 leaves it zero when absent; removing or renaming a field
35+// changes the persisted contract.
36+//
37+// Historical fields like `branch` and `isolated` are silently dropped
38+// by encoding/json's permissive decoder when an older meta.json is
39+// loaded; we don't need to keep them in the struct.
40+type agentMeta struct {
41+ ID string `json:"id"`
42+ Task string `json:"task"`
43+ OriginalTask string `json:"original_task,omitempty"`
44+ Dir string `json:"dir"`
45+ RepositoryRoot string `json:"repository_root,omitempty"`
46+ Started time.Time `json:"started"`
47+ ParentID string `json:"parent_id,omitempty"`
48+ BatchID string `json:"batch_id,omitempty"`
49+ RootSessionID string `json:"root_session_id,omitempty"`
50+ Model string `json:"model,omitempty"`
51+ Provider string `json:"provider,omitempty"`
52+ BaseURL string `json:"base_url,omitempty"`
53+ InsecureTLS bool `json:"insecure_tls,omitempty"`
54+ Reasoning string `json:"reasoning,omitempty"`
55+ FastMode *bool `json:"fast_mode,omitempty"`
56+ Subagent string `json:"subagent,omitempty"`
57+ WorkspaceMode WorkspaceMode `json:"workspace_mode,omitempty"`
58+ WorkspacePath string `json:"workspace_path,omitempty"`
59+ WorkspaceBase string `json:"workspace_base,omitempty"`
60+ WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"`
61+ MaxTurns int `json:"max_turns,omitempty"`
62+ Timeout time.Duration `json:"timeout,omitempty"`
63+ Tools []string `json:"tools,omitempty"`
64+ ProcessState ProcessState `json:"process_state,omitempty"`
65+ TurnState TurnState `json:"turn_state,omitempty"`
66+ CurrentTurnID string `json:"current_turn_id,omitempty"`
67+ Attempt int `json:"attempt,omitempty"`
68+ ProcessPID int `json:"process_pid,omitempty"`
69+ UpdatedAt time.Time `json:"updated_at,omitempty"`
70+ LastActivity time.Time `json:"last_activity,omitempty"`
71+ ResultRef string `json:"result_ref,omitempty"`
72+ PatchRef string `json:"patch_ref,omitempty"`
73+ ChangedFiles []string `json:"changed_files,omitempty"`
74+ InboxPath string `json:"inbox_path"`
75+ EventLogPath string `json:"event_log_path"`
76+ SessionPath string `json:"session_path"`
77+
78+ // SessionID, when non-empty, scopes the agent to a particular
79+ // host zot session: the dashboard only shows agents whose
80+ // SessionID matches the active session. Older meta.json files
81+ // (and agents spawned outside of any session, e.g. by tests or
82+ // scripted callers that didn't call SetActiveSession) have an
83+ // empty SessionID and are visible from every session as a
84+ // unscoped-agent fallback.
85+ SessionID string `json:"session_id,omitempty"`
86+}
87+
88+func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") }
89+
90+// writeAgentMeta serialises a's identity into stateDir/meta.json. The
91+// write is atomic (tmp + rename) so a crash mid-write can't leave a
92+// half-parsable file that fails Reload.
93+func writeAgentMeta(stateDir string, a *Agent) error {
94+ fastMode := a.FastMode
95+ a.lifecycleMu.Lock()
96+ processState := a.processState
97+ turnState := a.turnState
98+ currentTurnID := a.currentTurnID
99+ attempt := a.Attempt
100+ processPID := a.ProcessPID
101+ updatedAt := a.updatedAt
102+ lastActivity := a.lastActivity
103+ resultRef := a.resultRef
104+ patchRef := a.patchRef
105+ changedFiles := append([]string(nil), a.changedFiles...)
106+ a.lifecycleMu.Unlock()
107+ m := agentMeta{
108+ ID: a.ID,
109+ Task: a.Task,
110+ OriginalTask: a.OriginalTask,
111+ Dir: a.Dir,
112+ RepositoryRoot: a.RepositoryRoot,
113+ Started: a.Started,
114+ ParentID: a.ParentID,
115+ BatchID: a.BatchID,
116+ RootSessionID: a.RootSessionID,
117+ Model: a.Model,
118+ Provider: a.Provider,
119+ BaseURL: a.BaseURL,
120+ InsecureTLS: a.InsecureTLS,
121+ Reasoning: a.Reasoning,
122+ FastMode: &fastMode,
123+ Subagent: a.Subagent,
124+ WorkspaceMode: a.WorkspaceMode,
125+ WorkspacePath: a.WorkspacePath,
126+ WorkspaceBase: a.WorkspaceBase,
127+ WorkspaceCapture: a.WorkspaceCapture,
128+ MaxTurns: a.MaxTurns,
129+ Timeout: a.Timeout,
130+ Tools: append([]string(nil), a.Tools...),
131+ ProcessState: processState,
132+ TurnState: turnState,
133+ CurrentTurnID: currentTurnID,
134+ Attempt: attempt,
135+ ProcessPID: processPID,
136+ UpdatedAt: updatedAt,
137+ LastActivity: lastActivity,
138+ ResultRef: resultRef,
139+ PatchRef: patchRef,
140+ ChangedFiles: changedFiles,
141+ InboxPath: a.InboxPath,
142+ EventLogPath: a.EventLogPath,
143+ SessionPath: a.SessionPath,
144+ SessionID: a.SessionID,
145+ }
146+ b, err := json.MarshalIndent(m, "", " ")
147+ if err != nil {
148+ return fmt.Errorf("subagent meta marshal: %w", err)
149+ }
150+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
151+ return fmt.Errorf("subagents meta dir: %w", err)
152+ }
153+ final := metaPath(stateDir)
154+ tmp := final + ".tmp"
155+ if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
156+ return fmt.Errorf("subagents meta write: %w", err)
157+ }
158+ if err := os.Rename(tmp, final); err != nil {
159+ _ = os.Remove(tmp)
160+ return fmt.Errorf("subagent meta rename: %w", err)
161+ }
162+ return nil
163+}
164+
165+// readAgentMeta loads one meta.json. Returns os.ErrNotExist (wrapped)
166+// when the file is missing so callers can distinguish "no such agent"
167+// from "corrupt metadata".
168+func readAgentMeta(stateDir string) (agentMeta, error) {
169+ var m agentMeta
170+ b, err := os.ReadFile(metaPath(stateDir))
171+ if err != nil {
172+ return m, err
173+ }
174+ if err := json.Unmarshal(b, &m); err != nil {
175+ return m, fmt.Errorf("subagent meta parse %s: %w", stateDir, err)
176+ }
177+ if m.ID == "" {
178+ return m, fmt.Errorf("subagent meta %s: missing id", stateDir)
179+ }
180+ return m, nil
181+}
182+
183+func safeAgentID(id string) bool {
184+ return id != "" && id != "." && id != ".." && filepath.Base(id) == id && !strings.ContainsAny(id, `/\\`)
185+}
186+
187+func (f *Supervisor) sanitizeReloadMeta(stateDir, root string, m *agentMeta) error {
188+ if m == nil {
189+ return errors.New("subagents reload: nil metadata")
190+ }
191+ if m.EventLogPath == "" {
192+ m.EventLogPath = filepath.Join(stateDir, "events.jsonl")
193+ }
194+ if m.SessionPath == "" {
195+ m.SessionPath = filepath.Join(stateDir, "session.json")
196+ }
197+ if filepath.Base(m.EventLogPath) != "events.jsonl" || !pathWithin(m.EventLogPath, stateDir) {
198+ return fmt.Errorf("subagents reload %s: event log path escapes agent state", stateDir)
199+ }
200+ if filepath.Base(m.SessionPath) != "session.json" || !pathWithin(m.SessionPath, stateDir) {
201+ return fmt.Errorf("subagents reload %s: session path escapes agent state", stateDir)
202+ }
203+ if m.InboxPath == "" || !validRuntimeInboxPath(m.InboxPath, m.ID) {
204+ // Old metadata may point at a runtime directory from a previous
205+ // environment. Recompute that transient path rather than ever
206+ // dialing an arbitrary socket supplied by metadata.
207+ expectedInbox, err := inboxSocketPath(root, m.ID)
208+ if err != nil {
209+ return fmt.Errorf("subagents reload %s: inbox path: %w", stateDir, err)
210+ }
211+ m.InboxPath = expectedInbox
212+ }
213+ return nil
214+}
215+
216+func validRuntimeInboxPath(path, id string) bool {
217+ if !filepath.IsAbs(path) || (filepath.Base(path) != id+".sock" && filepath.Base(path) != shortHash(id)+".sock") {
218+ return false
219+ }
220+ dir := filepath.Dir(path)
221+ if !strings.HasPrefix(filepath.Base(dir), "zot-subagents-") {
222+ return false
223+ }
224+ bases := []string{os.TempDir(), "/tmp"}
225+ if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); filepath.IsAbs(runtimeDir) {
226+ bases = append(bases, runtimeDir)
227+ }
228+ for _, base := range bases {
229+ if pathWithin(dir, filepath.Clean(base)) {
230+ return true
231+ }
232+ }
233+ return false
234+}
235+
236+func firstNonEmpty(values ...string) string {
237+ for _, value := range values {
238+ if strings.TrimSpace(value) != "" {
239+ return value
240+ }
241+ }
242+ return ""
243+}
244+
245+// Reload scans <root>/agents/*/meta.json and re-registers every
246+// previously-spawned agent as a StatusDetached entry. Agents already
247+// present in memory are left alone (Reload is idempotent and safe to
248+// call after Spawn, though in practice the cli invokes it exactly
249+// once just after New()).
250+//
251+// The reloaded agents have no live Runner; the user can:
252+// - view their transcript (the dashboard reads from EventLogPath),
253+// - resume them via Supervisor.Resume (starts a fresh subprocess on the
254+// same worktree / session / inbox path),
255+// - remove them (worktree + meta + events log gone).
256+//
257+// Reload returns the number of agents loaded plus any per-directory
258+// error encountered. Malformed entries are skipped rather than
259+// failing the whole reload — one bad meta.json shouldn't hide the
260+// rest of the subagents.
261+func (f *Supervisor) Reload() (loaded int, errs []error) {
262+ root := filepath.Clean(f.cfg.Root)
263+ if root == "." {
264+ return 0, nil
265+ }
266+ loaded, errs = f.reloadRoot(root)
267+ errs = append(errs, f.reloadBatches(root)...)
268+ return loaded, errs
269+}
270+
271+func (f *Supervisor) reloadRoot(root string) (loaded int, errs []error) {
272+ agentsDir := filepath.Join(root, "agents")
273+ entries, err := os.ReadDir(agentsDir)
274+ if err != nil {
275+ if errors.Is(err, os.ErrNotExist) {
276+ return 0, nil
277+ }
278+ return 0, []error{fmt.Errorf("subagents reload %s: %w", root, err)}
279+ }
280+
281+ // Sort by directory name so the load order is stable across runs.
282+ names := make([]string, 0, len(entries))
283+ for _, e := range entries {
284+ if e.IsDir() {
285+ names = append(names, e.Name())
286+ }
287+ }
288+ sort.Strings(names)
289+
290+ for _, name := range names {
291+ stateDir := filepath.Join(agentsDir, name)
292+ m, err := readAgentMeta(stateDir)
293+ if err != nil {
294+ if errors.Is(err, os.ErrNotExist) {
295+ continue
296+ }
297+ errs = append(errs, err)
298+ continue
299+ }
300+ if filepath.Base(stateDir) != m.ID || !safeAgentID(m.ID) {
301+ errs = append(errs, fmt.Errorf("subagents reload %s: unsafe or mismatched agent id", stateDir))
302+ continue
303+ }
304+ if err := f.sanitizeReloadMeta(stateDir, root, &m); err != nil {
305+ errs = append(errs, err)
306+ continue
307+ }
308+
309+ f.mu.Lock()
310+ if _, exists := f.agents[m.ID]; exists {
311+ f.mu.Unlock()
312+ continue
313+ }
314+ a := f.buildDetachedAgent(m)
315+ f.agents[m.ID] = a
316+ f.order = append(f.order, m.ID)
317+ f.mu.Unlock()
318+ loaded++
319+ }
320+ return loaded, errs
321+}
322+
323+// buildDetachedAgent constructs an Agent from a meta.json with no
324+// running Runner. The agent's transcript is populated from the tail
325+// of its event log so the dashboard immediately shows recent output;
326+// activity is inferred from the last lifecycle event.
327+//
328+// The returned Agent has a closed `done` channel because Wait should
329+// return instantly: there is nothing to wait for.
330+func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
331+ // Metadata without a repository identity uses the current RepoRoot;
332+ // records with one retain their persisted repository so a restart from
333+ // another checkout cannot silently redirect the child.
334+ dir := m.Dir
335+ if m.WorkspaceMode != WorkspaceWorktree && m.RepositoryRoot == "" && f.cfg.RepoRoot != "" {
336+ // Preserve the best-effort migration only for records without an
337+ // explicit repository identity.
338+ dir = f.cfg.RepoRoot
339+ }
340+ if dir == "" {
341+ dir = firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot)
342+ }
343+ fastMode := f.cfg.FastMode
344+ if m.FastMode != nil {
345+ fastMode = *m.FastMode
346+ }
347+ turnState := m.TurnState
348+ if turnState == "" {
349+ turnState = TurnIdle
350+ }
351+ lastActivity := m.LastActivity
352+ if lastActivity.IsZero() {
353+ lastActivity = m.Started
354+ }
355+ processState := ProcessDetached
356+ activity := "detached"
357+ if inboxLive(m.InboxPath) {
358+ processState = ProcessAlive
359+ activity = "live (detached)"
360+ }
361+ agentStateDir := f.agentStateDir(m.ID)
362+ if m.EventLogPath != "" {
363+ // EventLogPath is <state-dir>/events.jsonl; keep the reloaded
364+ // agent's state scoped to its own directory. Using the parent
365+ // agents/ directory here would make Remove delete sibling agents
366+ // and would make resumed result/patch writes collide.
367+ agentStateDir = filepath.Dir(m.EventLogPath)
368+ }
369+ a := &Agent{
370+ ID: m.ID,
371+ Task: m.Task,
372+ OriginalTask: firstNonEmpty(m.OriginalTask, m.Task),
373+ Dir: dir,
374+ RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot),
375+ Started: m.Started,
376+ ParentID: m.ParentID,
377+ BatchID: m.BatchID,
378+ RootSessionID: m.RootSessionID,
379+ Model: m.Model,
380+ Provider: m.Provider,
381+ BaseURL: m.BaseURL,
382+ InsecureTLS: m.InsecureTLS,
383+ Reasoning: m.Reasoning,
384+ FastMode: fastMode,
385+ Subagent: m.Subagent,
386+ WorkspaceMode: m.WorkspaceMode,
387+ WorkspacePath: m.WorkspacePath,
388+ WorkspaceBase: m.WorkspaceBase,
389+ WorkspaceCapture: m.WorkspaceCapture,
390+ MaxTurns: m.MaxTurns,
391+ Timeout: m.Timeout,
392+ Tools: append([]string(nil), m.Tools...),
393+ Attempt: m.Attempt,
394+ ProcessPID: m.ProcessPID,
395+ InboxPath: m.InboxPath,
396+ EventLogPath: m.EventLogPath,
397+ SessionPath: m.SessionPath,
398+ SessionID: m.SessionID,
399+ inbox: NewInbox(m.InboxPath),
400+ status: StatusDetached,
401+ activity: activity,
402+ processState: processState,
403+ turnState: turnState,
404+ currentTurnID: m.CurrentTurnID,
405+ updatedAt: m.UpdatedAt,
406+ lastActivity: lastActivity,
407+ stateDir: agentStateDir,
408+ resultRef: m.ResultRef,
409+ patchRef: m.PatchRef,
410+ changedFiles: append([]string(nil), m.ChangedFiles...),
411+ maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
412+ maxOutputLines: f.cfg.Policy.MaxOutputLines,
413+ done: make(chan struct{}),
414+ turnResults: make(chan *TurnResult, 16),
415+ }
416+ if a.updatedAt.IsZero() {
417+ a.updatedAt = lastActivity
418+ }
419+ a.closeDone()
420+
421+ // Recover transcript + activity hints from the event log. Best
422+ // effort: a missing or unreadable log just leaves the agent
423+ // detached with an empty transcript.
424+ if a.EventLogPath != "" {
425+ if evs, err := ReadEventLog(a.EventLogPath); err == nil {
426+ replayEventsIntoAgent(a, evs)
427+ }
428+ }
429+ // The append-only log may contain a terminal event from an earlier
430+ // attempt. A live socket is stronger evidence for the current process;
431+ // keep the reloaded entry detached/live so Stop and Remove cannot treat a
432+ // resumed worker as already finished.
433+ if inboxLive(a.InboxPath) {
434+ a.mu.Lock()
435+ a.status = StatusDetached
436+ a.activity = "live (detached)"
437+ a.mu.Unlock()
438+ a.setProcessState(ProcessAlive)
439+ }
440+ resultDir := f.agentStateDir(a.ID)
441+ if a.EventLogPath != "" {
442+ resultDir = filepath.Dir(a.EventLogPath)
443+ }
444+ if result, err := readTurnResult(resultDir); err == nil && validateTurnResultAgent(result, a.ID) == nil {
445+ a.setResult(result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines))
446+ }
447+ return a
448+}
449+
450+// replayEventsIntoAgent re-derives an agent's transcript and last
451+// known status hint from its event log. Mirrors applyEventToSink in
452+// runner.go but writes directly to the Agent fields (no Sink because
453+// the agent isn't being driven by a runner yet).
454+//
455+// Status precedence: explicit lifecycle events (agent_stopped) win
456+// over inferred ones (assistant_message → idle). If the log contains
457+// no terminator we keep status=StatusDetached so the user can resume.
458+func replayEventsIntoAgent(a *Agent, evs []Event) {
459+ terminal := false
460+ for _, ev := range evs {
461+ switch ev.Type {
462+ case EventTurnStarted, "turn_start":
463+ turnID := ev.TurnID
464+ if turnID == "" {
465+ if step, ok := ev.Data["step"].(float64); ok {
466+ turnID = fmt.Sprintf("turn-%d", int(step))
467+ }
468+ }
469+ a.setTurnState(TurnRunning, turnID)
470+ case EventTurnResult, "turn_result":
471+ if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {
472+ a.setResult(result)
473+ if result.Status == ResultFailed {
474+ a.setTurnState(TurnFailed, result.TurnID)
475+ } else if result.Status == ResultCanceled {
476+ a.setTurnState(TurnCanceled, result.TurnID)
477+ } else {
478+ a.setTurnState(TurnSucceeded, result.TurnID)
479+ }
480+ }
481+ case EventAgentIdle, "agent_idle":
482+ a.setTurnState(TurnIdle, ev.TurnID)
483+ case EventAgentExited, "agent_exited":
484+ terminal = true
485+ a.mu.Lock()
486+ a.status = StatusDone
487+ a.activity = "done (offline)"
488+ a.mu.Unlock()
489+ case "assistant_message", "user_message":
490+ var text []string
491+ if c, ok := ev.Data["content"].([]any); ok {
492+ for _, blk := range c {
493+ m, _ := blk.(map[string]any)
494+ if t, _ := m["type"].(string); t == "text" {
495+ if txt, _ := m["text"].(string); txt != "" {
496+ text = append(text, txt)
497+ }
498+ }
499+ }
500+ }
501+ message := strings.Join(text, "\n")
502+ if ev.Type == "assistant_message" {
503+ a.appendAssistantMessage(message)
504+ } else {
505+ a.appendUserMessage(message)
506+ }
507+ case "stdout":
508+ if txt, _ := ev.Data["text"].(string); txt != "" {
509+ a.appendTranscript(txt)
510+ }
511+ case "stderr":
512+ if txt, _ := ev.Data["text"].(string); txt != "" {
513+ a.appendTranscript("stderr: " + txt)
514+ }
515+ case "error":
516+ if msg, _ := ev.Data["message"].(string); msg != "" {
517+ a.appendTranscript("error: " + msg)
518+ }
519+ case "agent_stopped":
520+ terminal = true
521+ reason, _ := ev.Data["reason"].(string)
522+ a.mu.Lock()
523+ switch reason {
524+ case "cancelled":
525+ a.status = StatusKilled
526+ a.activity = "cancelled (offline)"
527+ case "shutdown":
528+ a.status = StatusDone
529+ a.activity = "shutdown (offline)"
530+ case "exit":
531+ if code, ok := ev.Data["code"].(float64); ok && code != 0 {
532+ a.status = StatusFailed
533+ a.activity = fmt.Sprintf("exit %d (offline)", int(code))
534+ } else {
535+ a.status = StatusDone
536+ a.activity = "done (offline)"
537+ }
538+ default:
539+ a.status = StatusDone
540+ a.activity = "stopped (offline)"
541+ }
542+ a.mu.Unlock()
543+ }
544+ }
545+ if !terminal {
546+ // Non-terminal log means the previous parent died mid-run.
547+ // Leave status=StatusDetached but record a hint so the
548+ // dashboard shows something useful.
549+ a.mu.Lock()
550+ if a.activity == "detached" && len(a.transcript) > 0 {
551+ a.activity = "detached (resume to continue)"
552+ }
553+ a.mu.Unlock()
554+ }
555+}
556+
557+// Resume re-attaches a Runner to a previously-spawned agent. Durable
558+// session and event files are kept, while the transient inbox path is
559+// recalculated for the current runtime environment. Use this to
560+// continue a subagent session across zot restarts:
561+//
562+// subagentMgr.Reload()
563+// a, err := subagentMgr.Resume(ctx, "alpha-12345")
564+// subagentMgr.SendUserTurn(a.ID, "where were we?")
565+//
566+// The agent must be in a non-running state (Detached, Done, Failed,
567+// Killed). Resuming a still-running agent returns an error so two
568+// runners don't race for the same session.
569+func (f *Supervisor) Resume(ctx context.Context, id string) (*Agent, error) {
570+ return f.resume(ctx, id, true)
571+}
572+
573+// ResumeSession continues the existing child session without replaying the
574+// original task. It is the explicit canonical spelling for Resume.
575+func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, error) {
576+ return f.resume(ctx, id, true)
577+}
578+
579+// RestartTask intentionally runs the stored original task again in a fresh
580+// worker attempt. Callers should use ResumeSession for normal recovery.
581+func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) {
582+ return f.resume(ctx, id, false)
583+}
584+
585+func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Agent, error) {
586+ f.operationMu.Lock()
587+ defer f.operationMu.Unlock()
588+ if ctx == nil {
589+ ctx = context.Background()
590+ }
591+ existing := f.Get(id)
592+ if existing == nil {
593+ return nil, fmt.Errorf("subagent: no such agent %q", id)
594+ }
595+ existing.mu.Lock()
596+ st := existing.status
597+ existing.mu.Unlock()
598+ if st == StatusRunning || st == StatusPending {
599+ return nil, fmt.Errorf("subagent: agent %s is still %s; stop it first", existing.ID, st)
600+ }
601+ select {
602+ case <-existing.done:
603+ default:
604+ return nil, fmt.Errorf("subagent: agent %s is still finalizing; wait before resuming", existing.ID)
605+ }
606+ // A reloaded worker may still be alive even though this supervisor has no
607+ // in-process Runner (the old host can disappear after the child daemon
608+ // starts). Fence resume before touching the shared session/worktree: a
609+ // second worker must never own the same inbox and files concurrently.
610+ if inboxLive(existing.InboxPath) {
611+ return nil, fmt.Errorf("subagent: agent %s still has a live worker; send shutdown or stop it first", existing.ID)
612+ }
613+ stateDir := existing.stateDirectory(f.cfg.Root)
614+ lease, err := acquireAgentLease(stateDir)
615+ if err != nil {
616+ return nil, fmt.Errorf("subagents resume lease: %w", err)
617+ }
618+ leaseOwned := true
619+ defer func() {
620+ if leaseOwned {
621+ _ = lease.Close()
622+ }
623+ }()
624+ existingSnapshot := existing.Snapshot()
625+
626+ // Rebuild from the meta record so we don't carry stale runner
627+ // state from a previous incarnation. The inbox is transient and
628+ // may have been persisted under an incompatible filesystem by an
629+ // older zot version, so always select it again on resume.
630+ inboxPath, err := inboxSocketPath(f.cfg.Root, existing.ID)
631+ if err != nil {
632+ return nil, fmt.Errorf("subagent inbox path: %w", err)
633+ }
634+ fastMode := existing.FastMode
635+ m := agentMeta{
636+ ID: existing.ID, Task: existing.Task,
637+ OriginalTask: existing.OriginalTask,
638+ Dir: existing.Dir, RepositoryRoot: existing.RepositoryRoot, Started: existing.Started,
639+ ParentID: existing.ParentID, BatchID: existing.BatchID, RootSessionID: existing.RootSessionID,
640+ Model: existing.Model, Provider: existing.Provider,
641+ BaseURL: existing.BaseURL, InsecureTLS: existing.InsecureTLS,
642+ Reasoning: existing.Reasoning, FastMode: &fastMode,
643+ Subagent: existing.Subagent,
644+ WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath,
645+ WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture,
646+ MaxTurns: existing.MaxTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...),
647+ CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,
648+ SessionID: existing.SessionID,
649+ InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
650+ SessionPath: existing.SessionPath,
651+ }
652+
653+ if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 {
654+ m.Tools = append([]string(nil), f.cfg.Policy.AllowedTools...)
655+ }
656+ if err := f.validateSpawnOptions(SpawnRequest{MaxTurns: m.MaxTurns, Tools: m.Tools}); err != nil {
657+ return nil, err
658+ }
659+ workspaceMode := m.WorkspaceMode
660+ if workspaceMode == "" {
661+ workspaceMode = WorkspaceShared
662+ }
663+ if workspaceMode != WorkspaceShared && workspaceMode != WorkspaceWorktree {
664+ return nil, fmt.Errorf("subagents: unknown workspace mode %q", workspaceMode)
665+ }
666+ if m.WorkspaceCapture != "" && m.WorkspaceCapture != CapturePatch && m.WorkspaceCapture != CaptureDiff {
667+ return nil, fmt.Errorf("subagents: unknown workspace capture mode %q", m.WorkspaceCapture)
668+ }
669+ f.mu.Lock()
670+ allowedRoots := append([]string(nil), f.cfg.Policy.AllowedRoots...)
671+ f.mu.Unlock()
672+ repositoryRoot := firstNonEmpty(m.RepositoryRoot, m.Dir, f.cfg.RepoRoot)
673+ if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil {
674+ return nil, err
675+ }
676+ now := f.cfg.Now()
677+ maxTurns := m.MaxTurns
678+ if maxTurns <= 0 {
679+ maxTurns = f.cfg.Policy.MaxTurns
680+ }
681+ existingWorkspacePath := ""
682+ if workspaceMode == WorkspaceWorktree {
683+ existingWorkspacePath = m.WorkspacePath
684+ }
685+ workspace, err := PrepareWorkspace(ctx, WorkspaceRequest{
686+ Mode: workspaceMode,
687+ RepositoryRoot: repositoryRoot,
688+ StateDir: stateDir,
689+ AgentID: existing.ID,
690+ Base: m.WorkspaceBase,
691+ Capture: m.WorkspaceCapture,
692+ AllowedRoots: allowedRoots,
693+ ExistingPath: existingWorkspacePath,
694+ })
695+ if err != nil {
696+ return nil, err
697+ }
698+ m.Dir = workspace.Dir()
699+ m.RepositoryRoot = workspace.RepositoryRoot()
700+ m.WorkspacePath = workspace.Dir()
701+ m.WorkspaceMode = workspace.Mode()
702+ timeout := m.Timeout
703+ if timeout <= 0 {
704+ timeout = f.cfg.Policy.DefaultTimeout
705+ }
706+ runCtx, cancel := f.workerContext(timeout)
707+ a := &Agent{
708+ ID: m.ID,
709+ Task: m.Task,
710+ OriginalTask: firstNonEmpty(m.OriginalTask, m.Task),
711+ Dir: m.Dir,
712+ RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot),
713+ Started: m.Started,
714+ ParentID: m.ParentID,
715+ BatchID: m.BatchID,
716+ RootSessionID: m.RootSessionID,
717+ Model: m.Model,
718+ Provider: m.Provider,
719+ BaseURL: m.BaseURL,
720+ InsecureTLS: m.InsecureTLS,
721+ Reasoning: m.Reasoning,
722+ FastMode: fastMode,
723+ Subagent: m.Subagent,
724+ SessionID: m.SessionID,
725+ WorkspaceMode: m.WorkspaceMode,
726+ WorkspacePath: m.WorkspacePath,
727+ WorkspaceBase: m.WorkspaceBase,
728+ WorkspaceCapture: m.WorkspaceCapture,
729+ MaxTurns: maxTurns,
730+ Timeout: timeout,
731+ Attempt: m.Attempt,
732+ HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,
733+ Tools: append([]string(nil), m.Tools...),
734+ InboxPath: m.InboxPath,
735+ EventLogPath: m.EventLogPath,
736+ SessionPath: m.SessionPath,
737+ Resuming: resuming,
738+ inbox: NewInbox(m.InboxPath),
739+ status: StatusPending,
740+ activity: "resuming",
741+ processState: ProcessPending,
742+ turnState: TurnQueued,
743+ updatedAt: now,
744+ lastActivity: now,
745+ currentTurnID: m.CurrentTurnID,
746+ stateDir: stateDir,
747+ lease: lease,
748+ maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
749+ maxOutputLines: f.cfg.Policy.MaxOutputLines,
750+ done: make(chan struct{}),
751+ turnResults: make(chan *TurnResult, 16),
752+ }
753+ // Carry the previous transcript forward so the dashboard doesn't
754+ // flash empty between resume and the first new event.
755+ prev := existing.Transcript()
756+ if len(prev) > 0 {
757+ a.appendTranscript(strings.Join(prev, "\n"))
758+ }
759+ a.ctx, a.cancel = runCtx, cancel
760+ a.persistFn = f.persistAgent
761+ a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
762+ a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
763+ a.runner = f.cfg.NewRunner(a)
764+ if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil {
765+ if a.cancel != nil {
766+ a.cancel()
767+ }
768+ _ = workspace.Cleanup(context.Background())
769+ return nil, fmt.Errorf("subagents resume metadata: %w", err)
770+ }
771+
772+ f.mu.Lock()
773+ f.agents[a.ID] = a
774+ f.queue = append(f.queue, a)
775+ // Keep the agent's slot in f.order; replacing in-place avoids
776+ // reshuffling the dashboard's row ordering on resume.
777+ found := false
778+ for _, k := range f.order {
779+ if k == a.ID {
780+ found = true
781+ break
782+ }
783+ }
784+ if !found {
785+ f.order = append(f.order, a.ID)
786+ }
787+ f.mu.Unlock()
788+ leaseOwned = false
789+
790+ // Refreshing metadata happened before queue admission, so a later
791+ // supervisor can reconstruct this resumed attempt even if the runner
792+ // has not reached its first event yet.
793+ f.armQueueTimeout(a)
794+ f.schedule()
795+ return a, nil
796+}
R packages/agent/swarm/persist_test.go → packages/agent/subagents/persist_test.go +328 -32 renamed, 69% similar
@@ -1,4 +1,4 @@
1-package swarm
1+package subagents
22
33 import (
44 "context"
@@ -64,7 +64,7 @@ func TestSpawnWritesMetaJSON(t *testing.T) {
6464 }
6565
6666 // TestReloadRebuildsDetachedAgents simulates a zot restart by spawning
67-// in one Swarm, throwing it away, then opening a fresh Swarm against
67+// in one Supervisor, throwing it away, then opening a fresh Supervisor against
6868 // the same root and calling Reload. The user-visible state — id,
6969 // task, branch, dir — must come back, and status must be Detached so
7070 // the dashboard can offer resume.
@@ -133,6 +133,89 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) {
133133 }
134134 }
135135
136+func TestResumePreservesPerAgentTimeout(t *testing.T) {
137+ root := t.TempDir()
138+ wantTimeout := 37 * time.Minute
139+ first := New(Config{
140+ Root: root, RepoRoot: root,
141+ NewRunner: func(a *Agent) Runner {
142+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
143+ <-ctx.Done()
144+ return ctx.Err()
145+ })
146+ },
147+ })
148+ a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "timeout test", Timeout: wantTimeout})
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ first.StopAll()
153+ a.Wait()
154+
155+ observedCh := make(chan time.Duration, 1)
156+ second := New(Config{
157+ Root: root, RepoRoot: root,
158+ Policy: SubagentPolicy{DefaultTimeout: time.Hour},
159+ NewRunner: func(a *Agent) Runner {
160+ observedCh <- a.Timeout
161+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
162+ <-ctx.Done()
163+ return ctx.Err()
164+ })
165+ },
166+ })
167+ if loaded, errs := second.Reload(); loaded != 1 || len(errs) != 0 {
168+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
169+ }
170+ resumed, err := second.ResumeSession(context.Background(), a.ID)
171+ if err != nil {
172+ t.Fatal(err)
173+ }
174+ var observed time.Duration
175+ deadline := time.NewTimer(time.Second)
176+ defer deadline.Stop()
177+ select {
178+ case observed = <-observedCh:
179+ case <-deadline.C:
180+ t.Fatal("timed out waiting for resumed runner to observe timeout")
181+ }
182+ if resumed.Timeout != wantTimeout || observed != wantTimeout {
183+ t.Fatalf("resumed timeout = %s, runner observed %s; want %s", resumed.Timeout, observed, wantTimeout)
184+ }
185+ second.StopAll()
186+ resumed.Wait()
187+}
188+
189+func TestReloadRejectsMetadataPathsOutsideState(t *testing.T) {
190+ root := t.TempDir()
191+ outside := filepath.Join(t.TempDir(), "outside.json")
192+ if err := os.WriteFile(outside, []byte("keep"), 0o600); err != nil {
193+ t.Fatal(err)
194+ }
195+ stateDir := filepath.Join(root, "agents", "agent-1")
196+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
197+ t.Fatal(err)
198+ }
199+ meta := agentMeta{
200+ ID: "agent-1", Task: "malicious", Dir: root,
201+ EventLogPath: outside, SessionPath: outside, InboxPath: outside,
202+ }
203+ data, err := json.Marshal(meta)
204+ if err != nil {
205+ t.Fatal(err)
206+ }
207+ if err := os.WriteFile(filepath.Join(stateDir, "meta.json"), data, 0o600); err != nil {
208+ t.Fatal(err)
209+ }
210+ f := New(Config{Root: root, RepoRoot: root})
211+ if loaded, errs := f.Reload(); loaded != 0 || len(errs) == 0 {
212+ t.Fatalf("malicious metadata reload loaded=%d errs=%v", loaded, errs)
213+ }
214+ if got, err := os.ReadFile(outside); err != nil || string(got) != "keep" {
215+ t.Fatalf("outside path was touched: data=%q err=%v", got, err)
216+ }
217+}
218+
136219 // TestReloadIsIdempotent calls Reload twice in a row and asserts the
137220 // second call neither duplicates rows nor errors.
138221 func TestReloadIsIdempotent(t *testing.T) {
@@ -165,7 +248,7 @@ func TestReloadIsIdempotent(t *testing.T) {
165248 // TestReloadReplaysTranscriptFromEventLog drops a curated events.jsonl
166249 // next to a meta.json and checks that the transcript surfaces in the
167250 // reloaded agent. This is the user-facing payoff of Reload: opening
168-// /swarm logs <id> after a restart shows what the agent said before.
251+// /subagents logs <id> after a restart shows what the agent said before.
169252 func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
170253 root := t.TempDir()
171254 id := "alpha-9"
@@ -308,6 +391,7 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
308391 if a.Status() != StatusKilled {
309392 t.Fatalf("post-stop status = %q; want killed", a.Status())
310393 }
394+ previousAttempt := a.Snapshot().Attempt
311395
312396 // Now resume.
313397 a2, err := f.Resume(context.Background(), a.ID)
@@ -326,6 +410,9 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
326410 if a2.InboxPath != originalInbox {
327411 t.Errorf("resume changed inbox path: %s vs %s", a2.InboxPath, originalInbox)
328412 }
413+ if got := a2.Snapshot().Attempt; got != previousAttempt+1 {
414+ t.Errorf("resume attempt = %d; want %d", got, previousAttempt+1)
415+ }
329416 // Two runner invocations: spawn + resume.
330417 deadline = time.Now().Add(time.Second)
331418 for atomic.LoadInt32(&runsDone) < 2 && time.Now().Before(deadline) {
@@ -344,9 +431,9 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
344431 }
345432 }
346433
347-func TestResumeRecalculatesLegacyStateInboxPath(t *testing.T) {
434+func TestResumeRecalculatesInvalidStateInboxPath(t *testing.T) {
348435 if runtime.GOOS == "windows" {
349- t.Skip("swarm inbox runtime relocation applies to Unix sockets")
436+ t.Skip("subagent inbox runtime relocation applies to Unix sockets")
350437 }
351438 root := t.TempDir()
352439 f := New(Config{
@@ -355,21 +442,21 @@ func TestResumeRecalculatesLegacyStateInboxPath(t *testing.T) {
355442 return RunnerFunc(func(context.Context, Sink) error { return nil })
356443 },
357444 })
358- a, err := f.Spawn(context.Background(), "legacy inbox")
445+ a, err := f.Spawn(context.Background(), "invalid inbox")
359446 if err != nil {
360447 t.Fatal(err)
361448 }
362449 a.Wait()
363450
364- legacyPath := filepath.Join(root, "agents", a.ID, "in.sock")
365- a.InboxPath = legacyPath
451+ invalidPath := filepath.Join(root, "agents", a.ID, "in.sock")
452+ a.InboxPath = invalidPath
366453 a2, err := f.Resume(context.Background(), a.ID)
367454 if err != nil {
368455 t.Fatalf("resume: %v", err)
369456 }
370457 a2.Wait()
371- if a2.InboxPath == legacyPath {
372- t.Fatalf("resume kept legacy state-directory inbox path %q", legacyPath)
458+ if a2.InboxPath == invalidPath {
459+ t.Fatalf("resume kept state-directory inbox path %q", invalidPath)
373460 }
374461 if strings.HasPrefix(a2.InboxPath, root+string(filepath.Separator)) {
375462 t.Fatalf("resumed inbox %q remains below state root %q", a2.InboxPath, root)
@@ -478,7 +565,7 @@ func TestResumeAfterReload(t *testing.T) {
478565 a.StopAll()
479566 ag.Wait()
480567
481- // Process B: a new Swarm against the same root.
568+ // Process B: a new Supervisor against the same root.
482569 resumed := make(chan struct{}, 1)
483570 b := New(Config{
484571 Root: root, RepoRoot: root,
@@ -525,6 +612,183 @@ func TestResumeAfterReload(t *testing.T) {
525612 t.Fatalf("resumed transcript missing fresh line: %v", a2.Transcript())
526613 }
527614
615+func TestStopMustFinishBeforeResumeOrRemove(t *testing.T) {
616+ root := t.TempDir()
617+ started := make(chan struct{})
618+ release := make(chan struct{})
619+ f := New(Config{
620+ Root: root, RepoRoot: root,
621+ NewRunner: func(*Agent) Runner {
622+ return RunnerFunc(func(context.Context, Sink) error {
623+ close(started)
624+ <-release
625+ return nil
626+ })
627+ },
628+ })
629+ a, err := f.Spawn(context.Background(), "slow stop")
630+ if err != nil {
631+ t.Fatal(err)
632+ }
633+ <-started
634+ if err := f.Stop(a.ID); err != nil {
635+ t.Fatal(err)
636+ }
637+ if _, err := f.Resume(context.Background(), a.ID); err == nil || !strings.Contains(err.Error(), "finalizing") {
638+ t.Fatalf("immediate resume error = %v, want finalizing", err)
639+ }
640+ if err := f.Remove(a.ID); err == nil || !strings.Contains(err.Error(), "finalizing") {
641+ t.Fatalf("immediate remove error = %v, want finalizing", err)
642+ }
643+ close(release)
644+ a.Wait()
645+}
646+
647+func TestResumeFencesLiveReloadedWorker(t *testing.T) {
648+ if runtime.GOOS == "windows" {
649+ t.Skip("subagent inbox transport uses Unix-domain sockets")
650+ }
651+ root := t.TempDir()
652+ first := New(Config{
653+ Root: root, RepoRoot: root,
654+ NewRunner: func(*Agent) Runner {
655+ return RunnerFunc(func(context.Context, Sink) error { return nil })
656+ },
657+ })
658+ a, err := first.Spawn(context.Background(), "live worker")
659+ if err != nil {
660+ t.Fatal(err)
661+ }
662+ a.Wait()
663+ ln, err := Listen(a.InboxPath)
664+ if err != nil {
665+ t.Fatal(err)
666+ }
667+ defer ln.Close()
668+
669+ second := New(Config{Root: root, RepoRoot: root})
670+ if loaded, errs := second.Reload(); loaded != 1 || len(errs) != 0 {
671+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
672+ }
673+ if got := second.Get(a.ID).ProcessState(); got != ProcessAlive {
674+ t.Fatalf("reloaded process state = %s, want alive", got)
675+ }
676+ if _, err := second.Resume(context.Background(), a.ID); err == nil || !strings.Contains(err.Error(), "live worker") {
677+ t.Fatalf("resume error = %v, want live-worker fence", err)
678+ }
679+}
680+
681+func TestSupervisorProviderSettingsPersistAcrossReloadAndResume(t *testing.T) {
682+ root := t.TempDir()
683+ newSupervisor := func(baseURL string, insecureTLS bool) *Supervisor {
684+ return New(Config{
685+ Root: root,
686+ RepoRoot: root,
687+ BaseURL: baseURL,
688+ InsecureTLS: insecureTLS,
689+ NewRunner: func(a *Agent) Runner {
690+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
691+ <-ctx.Done()
692+ return ctx.Err()
693+ })
694+ },
695+ })
696+ }
697+
698+ first := newSupervisor("https://parent.example.test/v1", true)
699+ a, err := first.SpawnReq(context.Background(), SpawnRequest{Task: "x"})
700+ if err != nil {
701+ t.Fatalf("spawn: %v", err)
702+ }
703+ if a.BaseURL != "https://parent.example.test/v1" || !a.InsecureTLS {
704+ t.Fatalf("spawned settings = (%q, %v), want inherited config", a.BaseURL, a.InsecureTLS)
705+ }
706+ if snap := a.Snapshot(); snap.BaseURL != a.BaseURL || snap.InsecureTLS != a.InsecureTLS {
707+ t.Fatalf("snapshot settings = (%q, %v), want (%q, %v)", snap.BaseURL, snap.InsecureTLS, a.BaseURL, a.InsecureTLS)
708+ }
709+ if err := first.Stop(a.ID); err != nil {
710+ t.Fatalf("stop: %v", err)
711+ }
712+ a.Wait()
713+
714+ metaBytes, err := os.ReadFile(filepath.Join(root, "agents", a.ID, "meta.json"))
715+ if err != nil {
716+ t.Fatalf("read meta.json: %v", err)
717+ }
718+ var meta agentMeta
719+ if err := json.Unmarshal(metaBytes, &meta); err != nil {
720+ t.Fatalf("decode meta.json: %v", err)
721+ }
722+ if meta.BaseURL != a.BaseURL || !meta.InsecureTLS {
723+ t.Fatalf("metadata settings = (%q, %v), want (%q, %v)", meta.BaseURL, meta.InsecureTLS, a.BaseURL, a.InsecureTLS)
724+ }
725+ if strings.Contains(string(metaBytes), "api_key") || strings.Contains(string(metaBytes), "parent-secret") {
726+ t.Fatalf("metadata contains credential material: %s", metaBytes)
727+ }
728+
729+ second := newSupervisor("https://different.example.test/v1", false)
730+ if loaded, errs := second.Reload(); loaded != 1 || len(errs) != 0 {
731+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
732+ }
733+ reloaded := second.Get(a.ID)
734+ if reloaded == nil || reloaded.BaseURL != a.BaseURL || !reloaded.InsecureTLS {
735+ t.Fatalf("reloaded settings = (%q, %v), want (%q, %v)", reloaded.BaseURL, reloaded.InsecureTLS, a.BaseURL, a.InsecureTLS)
736+ }
737+ resumed, err := second.Resume(context.Background(), a.ID)
738+ if err != nil {
739+ t.Fatalf("resume: %v", err)
740+ }
741+ if resumed.BaseURL != a.BaseURL || !resumed.InsecureTLS {
742+ t.Fatalf("resumed settings = (%q, %v), want (%q, %v)", resumed.BaseURL, resumed.InsecureTLS, a.BaseURL, a.InsecureTLS)
743+ }
744+ if err := second.Stop(resumed.ID); err != nil {
745+ t.Fatalf("stop resumed: %v", err)
746+ }
747+ resumed.Wait()
748+}
749+
750+func TestExplicitChildProviderDoesNotInheritHostEndpoint(t *testing.T) {
751+ f := New(Config{
752+ Root: t.TempDir(), RepoRoot: t.TempDir(), Provider: "anthropic",
753+ BaseURL: "https://host.example.test/v1", InsecureTLS: true,
754+ NewRunner: func(*Agent) Runner {
755+ return RunnerFunc(func(context.Context, Sink) error { return nil })
756+ },
757+ })
758+ a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x", Provider: "openai"})
759+ if err != nil {
760+ t.Fatal(err)
761+ }
762+ a.Wait()
763+ if a.BaseURL != "" || a.InsecureTLS {
764+ t.Fatalf("explicit provider inherited host settings: (%q, %v)", a.BaseURL, a.InsecureTLS)
765+ }
766+}
767+
768+func TestSpawnRequestProviderSettingsOverrideConfig(t *testing.T) {
769+ root := t.TempDir()
770+ f := New(Config{
771+ Root: root,
772+ RepoRoot: root,
773+ BaseURL: "https://config.example.test/v1",
774+ NewRunner: func(*Agent) Runner {
775+ return RunnerFunc(func(context.Context, Sink) error { return nil })
776+ },
777+ })
778+ a, err := f.SpawnReq(context.Background(), SpawnRequest{
779+ Task: "x",
780+ BaseURL: "https://request.example.test/v1",
781+ InsecureTLS: true,
782+ })
783+ if err != nil {
784+ t.Fatalf("spawn: %v", err)
785+ }
786+ a.Wait()
787+ if a.BaseURL != "https://request.example.test/v1" || !a.InsecureTLS {
788+ t.Fatalf("spawned settings = (%q, %v), want request override", a.BaseURL, a.InsecureTLS)
789+ }
790+}
791+
528792 // TestSpawnReqPersistsModel confirms that the per-agent model
529793 // override is captured at Spawn time, surfaced via Snapshot, and
530794 // written to meta.json so a later Reload + Resume reuses it.
@@ -570,7 +834,7 @@ func TestSpawnReqPersistsModel(t *testing.T) {
570834 t.Errorf("meta = (%q,%q,%q,%q,%q); want model/provider/reasoning/profile/session persisted", got.Model, got.Provider, got.Reasoning, got.Subagent, got.SessionID)
571835 }
572836
573- // Reload in a fresh Swarm and confirm the detached agent still
837+ // Reload in a fresh Supervisor and confirm the detached agent still
574838 // carries the model/provider so Resume can route the child
575839 // subprocess back to the same model.
576840 g := New(Config{
@@ -603,12 +867,12 @@ func TestSpawnReqPersistsModel(t *testing.T) {
603867 resumed.Wait()
604868 }
605869
606-// TestSwarmAgentArgsIncludesModelFlags pins the argv contract that
870+// TestSubagentWorkerArgsIncludesModelFlags pins the argv contract that
607871 // connects the supervisor's per-agent model override to the child's
608872 // --model / --provider flag set. Adding a new path to argv without
609873 // updating this assertion is the failure mode this catches.
610-func TestSwarmAgentArgsIncludesModelFlags(t *testing.T) {
611- args := swarmAgentArgs(swarmAgentArgsOpts{
874+func TestSubagentWorkerArgsIncludesModelFlags(t *testing.T) {
875+ args := subagentWorkerArgs(subagentWorkerArgsOpts{
612876 Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",
613877 Task: "do x", Model: "gpt-5", Provider: "openai",
614878 })
@@ -634,14 +898,14 @@ func TestSwarmAgentArgsIncludesModelFlags(t *testing.T) {
634898
635899 // TestStopOnDetachedAgentIsNoopAndDoesNotPanic regression-tests the
636900 // segfault that hit when the user pressed 'k' on a reloaded
637-// (detached) agent: Swarm.Stop unconditionally called a.cancel(),
901+// (detached) agent: Supervisor.Stop unconditionally called a.cancel(),
638902 // but buildDetachedAgent never assigns a cancel func because there's
639903 // no in-process runner to cancel. The fix is to short-circuit Stop
640904 // for StatusDetached and — belt-and-braces — nil-check a.cancel.
641905 func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) {
642906 root := t.TempDir()
643907 // Build a detached agent the same way Reload does: drop a
644- // meta.json on disk, then ask a fresh Swarm to pick it up.
908+ // meta.json on disk, then ask a fresh Supervisor to pick it up.
645909 id := "alpha-1"
646910 stateDir := filepath.Join(root, "agents", id)
647911 if err := os.MkdirAll(stateDir, 0o755); err != nil {
@@ -666,7 +930,7 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) {
666930 }
667931 a := f.Get(id)
668932 if a == nil {
669- t.Fatal("detached agent missing from swarm")
933+ t.Fatal("detached agent missing from subagent")
670934 }
671935 if a.Status() != StatusDetached {
672936 t.Fatalf("setup: agent status = %q; want detached", a.Status())
@@ -720,15 +984,46 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) {
720984 t.Fatalf("state dir should be gone after remove; got %v", err)
721985 }
722986
723- // A fresh Swarm + Reload should find nothing.
987+ // A fresh Supervisor + Reload should find nothing.
724988 g := New(Config{Root: root, RepoRoot: root})
725989 if loaded, _ := g.Reload(); loaded != 0 {
726990 t.Fatalf("reload after remove loaded=%d; want 0", loaded)
727991 }
728992 }
729993
994+func TestRemoveReloadedAgentKeepsSiblingState(t *testing.T) {
995+ root := t.TempDir()
996+ first := New(Config{
997+ Root: root, RepoRoot: root,
998+ NewRunner: func(*Agent) Runner {
999+ return RunnerFunc(func(context.Context, Sink) error { return nil })
1000+ },
1001+ })
1002+ a1, err := first.Spawn(context.Background(), "first")
1003+ if err != nil {
1004+ t.Fatal(err)
1005+ }
1006+ a2, err := first.Spawn(context.Background(), "second")
1007+ if err != nil {
1008+ t.Fatal(err)
1009+ }
1010+ a1.Wait()
1011+ a2.Wait()
1012+
1013+ second := New(Config{Root: root, RepoRoot: root})
1014+ if loaded, errs := second.Reload(); loaded != 2 || len(errs) != 0 {
1015+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
1016+ }
1017+ if err := second.Remove(a1.ID); err != nil {
1018+ t.Fatal(err)
1019+ }
1020+ if _, err := os.Stat(filepath.Join(root, "agents", a2.ID, "meta.json")); err != nil {
1021+ t.Fatalf("removing reloaded sibling deleted surviving metadata: %v", err)
1022+ }
1023+}
1024+
7301025 // TestActiveSessionScopesSnapshotAll proves the session filter the
731-// user asked for: a single Swarm with agents spawned under two
1026+// user asked for: a single Supervisor with agents spawned under two
7321027 // different active sessions only surfaces the agents matching the
7331028 // currently-active session via SnapshotAll. Switching the active
7341029 // session re-narrows the view without touching agent state.
@@ -790,14 +1085,14 @@ func TestActiveSessionScopesSnapshotAll(t *testing.T) {
7901085 }
7911086
7921087 // TestSessionIDPersistsAcrossReload confirms the SessionID field
793-// survives a Swarm restart: an agent spawned under session A in one
794-// Swarm instance is still SessionID="sess-A" after a fresh New +
1088+// survives a Supervisor restart: an agent spawned under session A in one
1089+// Supervisor instance is still SessionID="sess-A" after a fresh New +
7951090 // Reload reads it back from meta.json. Without persistence, the
7961091 // scope filter would forget which session owned each agent after
7971092 // a zot restart and the dashboard would leak everything again.
7981093 func TestSessionIDPersistsAcrossReload(t *testing.T) {
7991094 root := t.TempDir()
800- mkSwarm := func() *Swarm {
1095+ mkSupervisor := func() *Supervisor {
8011096 return New(Config{
8021097 Root: root, RepoRoot: root,
8031098 NewRunner: func(a *Agent) Runner {
@@ -806,7 +1101,7 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) {
8061101 })
8071102 }
8081103
809- f := mkSwarm()
1104+ f := mkSupervisor()
8101105 f.SetActiveSession("sess-keep")
8111106 a, err := f.Spawn(context.Background(), "persist me")
8121107 if err != nil {
@@ -815,8 +1110,8 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) {
8151110 _ = f.Stop(a.ID)
8161111 a.Wait()
8171112
818- // Fresh Swarm, same root, reload from disk.
819- g := mkSwarm()
1113+ // Fresh Supervisor, same root, reload from disk.
1114+ g := mkSupervisor()
8201115 if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {
8211116 t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs)
8221117 }
@@ -826,14 +1121,15 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) {
8261121 t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID)
8271122 }
8281123
829- // Scope to a different session: agent must be hidden.
1124+ // A terminal persisted agent remains visible from another host session
1125+ // so a normal restart does not hide durable recovery state.
8301126 g.SetActiveSession("sess-other")
831- if got := snapshotIDs(g.SnapshotAll()); len(got) != 0 {
832- t.Errorf("scoped to other session, ids = %v; want []", got)
1127+ if got := snapshotIDs(g.SnapshotAll()); len(got) != 1 || got[0] != a.ID {
1128+ t.Errorf("scoped to other session, ids = %v; want [%s]", got, a.ID)
8331129 }
8341130 }
8351131
836-// TestEmptySessionIDIsVisibleFromAnyScope pins the backward-compat
1132+// TestEmptySessionIDIsVisibleFromAnyScope pins the unscoped-agent
8371133 // rule: agents whose meta.json predates the SessionID field (or who
8381134 // were spawned without an active session, e.g. via a test rig or
8391135 // scripted caller) carry SessionID == "" and remain visible from
@@ -848,7 +1144,7 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) {
8481144 },
8491145 })
8501146 // No SetActiveSession call → agent spawned with empty SessionID.
851- a, err := f.Spawn(context.Background(), "legacy")
1147+ a, err := f.Spawn(context.Background(), "unscoped")
8521148 if err != nil {
8531149 t.Fatal(err)
8541150 }
@@ -860,7 +1156,7 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) {
8601156 f.SetActiveSession(scope)
8611157 got := snapshotIDs(f.SnapshotAll())
8621158 if len(got) != 1 || got[0] != a.ID {
863- t.Errorf("scope=%q: ids = %v; want legacy agent visible", scope, got)
1159+ t.Errorf("scope=%q: ids = %v; want unscoped agent visible", scope, got)
8641160 }
8651161 }
8661162
A packages/agent/subagents/process_unix.go +69 -0
@@ -0,0 +1,69 @@
1+//go:build !windows
2+
3+package subagents
4+
5+import (
6+ "io"
7+ "os/exec"
8+ "sync"
9+ "syscall"
10+ "time"
11+)
12+
13+// configureWorkerProcess makes cancellation target the worker's process
14+// group rather than only the worker itself. CommandContext calls cmd.Cancel
15+// from its context watcher, so descendants that inherited the worker's group
16+// are terminated before execRunner waits for their output pipes to close.
17+func configureWorkerProcess(cmd *exec.Cmd, pipes ...io.Closer) {
18+ setProcessGroup(cmd)
19+ cmd.WaitDelay = workerProcessWaitDelay
20+ var closePipes sync.Once
21+ cmd.Cancel = func() error {
22+ err := killProcessGroup(cmd)
23+ closePipes.Do(func() { closeWorkerPipes(pipes) })
24+ return err
25+ }
26+}
27+
28+const workerProcessWaitDelay = 5 * time.Second
29+
30+// closeWorkerPipes closes the parent-side readers returned by StdoutPipe and
31+// StderrPipe. Closing these descriptors lets the custom reader goroutines exit
32+// if a descendant has outlived the worker process.
33+func closeWorkerPipes(pipes []io.Closer) {
34+ for _, pipe := range pipes {
35+ if pipe != nil {
36+ _ = pipe.Close()
37+ }
38+ }
39+}
40+
41+// setProcessGroup puts a worker in a process group of its own. This keeps a
42+// canceled worker from sharing a group with the supervising zot process.
43+func setProcessGroup(cmd *exec.Cmd) {
44+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
45+}
46+
47+// killProcessGroup forcefully terminates the worker group. Cancellation is a
48+// forceful path: the supervisor already offers a graceful shutdown through
49+// the worker inbox, while SIGKILL ensures a descendant cannot keep the
50+// worker's stdout or stderr pipe open indefinitely.
51+func killProcessGroup(cmd *exec.Cmd) error {
52+ if cmd == nil || cmd.Process == nil {
53+ return nil
54+ }
55+ return forceKillProcess(cmd.Process.Pid)
56+}
57+
58+func forceKillProcess(pid int) error {
59+ if pid <= 0 {
60+ return nil
61+ }
62+ // With Setpgid, the worker's PID is also the process-group ID. A negative
63+ // PID addresses the whole group rather than just the worker process.
64+ err := syscall.Kill(-pid, syscall.SIGKILL)
65+ if err == syscall.ESRCH {
66+ return nil
67+ }
68+ return err
69+}
A packages/agent/subagents/process_unix_test.go +106 -0
@@ -0,0 +1,106 @@
1+//go:build !windows
2+
3+package subagents
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "strconv"
13+ "strings"
14+ "syscall"
15+ "testing"
16+ "time"
17+)
18+
19+func TestWorkerCancellationKillsProcessGroup(t *testing.T) {
20+ shell, err := exec.LookPath("sh")
21+ if err != nil {
22+ t.Skip("sh is not available")
23+ }
24+
25+ pidPath := t.TempDir() + "/child.pid"
26+ ctx, cancel := context.WithCancel(context.Background())
27+ defer cancel()
28+
29+ cmd := exec.CommandContext(ctx, shell, "-c", fmt.Sprintf("sleep 30 & echo $! > %q; wait", pidPath))
30+ configureWorkerProcess(cmd)
31+ if cmd.WaitDelay != workerProcessWaitDelay {
32+ t.Fatalf("WaitDelay = %s, want %s", cmd.WaitDelay, workerProcessWaitDelay)
33+ }
34+ if err := cmd.Start(); err != nil {
35+ t.Fatal(err)
36+ }
37+
38+ childPID := waitForWorkerChildPID(t, pidPath)
39+ cancel()
40+
41+ waitDone := make(chan error, 1)
42+ go func() { waitDone <- cmd.Wait() }()
43+ select {
44+ case <-time.After(2 * time.Second):
45+ t.Fatal("worker did not exit after context cancellation")
46+ case err := <-waitDone:
47+ if err == nil {
48+ t.Fatal("worker exited successfully after context cancellation")
49+ }
50+ }
51+
52+ deadline := time.Now().Add(2 * time.Second)
53+ for time.Now().Before(deadline) && processExists(childPID) {
54+ time.Sleep(10 * time.Millisecond)
55+ }
56+ if processExists(childPID) {
57+ t.Fatalf("worker descendant pid %d survived process-group cancellation", childPID)
58+ }
59+}
60+
61+func TestWorkerCancellationClosesOutputPipes(t *testing.T) {
62+ cmd := exec.Command("sh")
63+ stdout, err := cmd.StdoutPipe()
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ stderr, err := cmd.StderrPipe()
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ defer stdout.Close()
72+ defer stderr.Close()
73+
74+ configureWorkerProcess(cmd, stdout, stderr)
75+ if err := cmd.Cancel(); err != nil {
76+ t.Fatal(err)
77+ }
78+ for name, pipe := range map[string]io.Reader{"stdout": stdout, "stderr": stderr} {
79+ data := make([]byte, 1)
80+ if _, err := pipe.Read(data); !errors.Is(err, os.ErrClosed) {
81+ t.Errorf("%s read error = %v, want os.ErrClosed", name, err)
82+ }
83+ }
84+}
85+
86+func waitForWorkerChildPID(t *testing.T, path string) int {
87+ t.Helper()
88+ deadline := time.Now().Add(2 * time.Second)
89+ for time.Now().Before(deadline) {
90+ data, err := os.ReadFile(path)
91+ if err == nil {
92+ pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data)))
93+ if parseErr == nil && pid > 0 {
94+ return pid
95+ }
96+ }
97+ time.Sleep(10 * time.Millisecond)
98+ }
99+ t.Fatalf("timed out waiting for child pid in %s", path)
100+ return 0
101+}
102+
103+func processExists(pid int) bool {
104+ err := syscall.Kill(pid, 0)
105+ return err == nil || errors.Is(err, syscall.EPERM)
106+}
A packages/agent/subagents/process_windows.go +99 -0
@@ -0,0 +1,99 @@
1+//go:build windows
2+
3+package subagents
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "strconv"
12+ "strings"
13+ "sync"
14+ "syscall"
15+ "time"
16+)
17+
18+// configureWorkerProcess makes CommandContext cancellation terminate the
19+// worker and its descendants on Windows. Windows does not provide a Unix-like
20+// process-group kill operation, so killProcessGroup uses taskkill's tree mode.
21+func configureWorkerProcess(cmd *exec.Cmd, pipes ...io.Closer) {
22+ setProcessGroup(cmd)
23+ cmd.WaitDelay = workerProcessWaitDelay
24+ var closePipes sync.Once
25+ cmd.Cancel = func() error {
26+ err := killProcessGroup(cmd)
27+ closePipes.Do(func() { closeWorkerPipes(pipes) })
28+ return err
29+ }
30+}
31+
32+func closeWorkerPipes(pipes []io.Closer) {
33+ for _, pipe := range pipes {
34+ if pipe != nil {
35+ _ = pipe.Close()
36+ }
37+ }
38+}
39+
40+const workerProcessWaitDelay = 5 * time.Second
41+
42+// setProcessGroup gives the worker its own Windows process group. The group
43+// flag prevents it from being attached to the supervisor's console group;
44+// taskkill /T below is still needed because Windows group creation alone does
45+// not terminate descendants.
46+func setProcessGroup(cmd *exec.Cmd) {
47+ cmd.SysProcAttr = &syscall.SysProcAttr{
48+ CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
49+ }
50+}
51+
52+// killProcessGroup terminates the worker process tree. taskkill is part of
53+// Windows and /T follows the parent-child relationships to include workers
54+// that spawned their own subprocesses.
55+func killProcessGroup(cmd *exec.Cmd) error {
56+ if cmd == nil || cmd.Process == nil {
57+ return nil
58+ }
59+ err := forceKillProcess(cmd.Process.Pid)
60+ var taskkillErr *taskkillError
61+ if errors.As(err, &taskkillErr) && taskkillErr.processDone() {
62+ return os.ErrProcessDone
63+ }
64+ return err
65+}
66+
67+func forceKillProcess(pid int) error {
68+ if pid <= 0 {
69+ return nil
70+ }
71+ output, err := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F").CombinedOutput()
72+ if err == nil {
73+ return nil
74+ }
75+ return &taskkillError{pid: pid, err: err, diagnostics: string(output)}
76+}
77+
78+type taskkillError struct {
79+ pid int
80+ err error
81+ diagnostics string
82+}
83+
84+func (e *taskkillError) Error() string {
85+ if diagnostics := strings.TrimSpace(e.diagnostics); diagnostics != "" {
86+ return fmt.Sprintf("taskkill process tree for PID %d: %v: %s", e.pid, e.err, diagnostics)
87+ }
88+ return fmt.Sprintf("taskkill process tree for PID %d: %v", e.pid, e.err)
89+}
90+
91+func (e *taskkillError) Unwrap() error { return e.err }
92+
93+// taskkill returns exit code 128 when the requested process no longer exists.
94+// Other taskkill failures remain errors so callers can distinguish a
95+// cancellation race from a real process-control failure.
96+func (e *taskkillError) processDone() bool {
97+ var exitErr *exec.ExitError
98+ return errors.As(e.err, &exitErr) && exitErr.ExitCode() == 128
99+}
M packages/agent/subagents/profiles.go +2 -2
@@ -1,5 +1,5 @@
11 // Package subagents discovers named agent profiles that can be selected by
2-// zot's swarm supervisor. Profiles use the common markdown/frontmatter layout:
2+// zot's subagent supervisor. Profiles use the common markdown/frontmatter layout:
33 // a YAML-like metadata block followed by the agent's system prompt.
44 //
55 // Discovery prefers explicitly configured directories, then the shared
@@ -132,7 +132,7 @@ func SystemPromptAddendum(profiles []*Profile) string {
132132 }
133133 var sb strings.Builder
134134 sb.WriteString("[subagents_list]\n")
135- sb.WriteString("Named subagents available to swarm_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n")
135+ sb.WriteString("Named subagents available to subagent_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n")
136136 for _, profile := range profiles {
137137 if profile == nil {
138138 continue
A packages/agent/subagents/protocol.go +629 -0
@@ -0,0 +1,629 @@
1+package subagents
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "strings"
11+ "time"
12+
13+ "github.com/google/uuid"
14+)
15+
16+// ProtocolVersion is the version of the supervisor/worker JSONL protocol.
17+const ProtocolVersion = 1
18+
19+// Canonical parent-to-worker command names.
20+const (
21+ CommandAgentPing = "agent.ping"
22+ CommandTurnStart = "turn.start"
23+ CommandTurnCancel = "turn.cancel"
24+ CommandAgentShutdown = "agent.shutdown"
25+)
26+
27+// Canonical worker-to-parent event names.
28+const (
29+ EventAgentReady = "agent.ready"
30+ EventAgentHeartbeat = "agent.heartbeat"
31+ EventTurnStarted = "turn.started"
32+ EventTurnProgress = "turn.progress"
33+ EventToolStarted = "tool.started"
34+ EventToolFinished = "tool.finished"
35+ EventMessageDelta = "message.delta"
36+ EventTurnResult = "turn.result"
37+ EventTurnFailed = "turn.failed"
38+ EventAgentIdle = "agent.idle"
39+ EventAgentExited = "agent.exited"
40+)
41+
42+var (
43+ // ErrEmptyProtocolLine is returned for a blank JSONL line or command.
44+ ErrEmptyProtocolLine = errors.New("subagent protocol: empty line")
45+ // ErrNotCommand is returned when ParseCommand is given a JSON event.
46+ ErrNotCommand = errors.New("subagent protocol: message is not a command")
47+ // ErrNotEvent is returned when ParseEvent is given a JSON command.
48+ ErrNotEvent = errors.New("subagent protocol: message is not an event")
49+ // ErrUnsupportedProtocolVersion is returned by Validate for a version
50+ // this implementation cannot interpret.
51+ ErrUnsupportedProtocolVersion = errors.New("subagent protocol: unsupported version")
52+)
53+
54+var commandNames = map[string]struct{}{
55+ CommandAgentPing: {},
56+ CommandTurnStart: {},
57+ CommandTurnCancel: {},
58+ CommandAgentShutdown: {},
59+}
60+
61+var eventNames = map[string]struct{}{
62+ EventAgentReady: {},
63+ EventAgentHeartbeat: {},
64+ EventTurnStarted: {},
65+ EventTurnProgress: {},
66+ EventToolStarted: {},
67+ EventToolFinished: {},
68+ EventMessageDelta: {},
69+ EventTurnResult: {},
70+ EventTurnFailed: {},
71+ EventAgentIdle: {},
72+ EventAgentExited: {},
73+}
74+
75+// IsCommandName reports whether name is one of the commands defined by this
76+// version of the protocol. Readers intentionally do not use this as a parse
77+// gate: a newer command can still be decoded and forwarded by an older
78+// supervisor.
79+func IsCommandName(name string) bool {
80+ _, ok := commandNames[name]
81+ return ok
82+}
83+
84+// IsEventName reports whether name is one of the events defined by this
85+// version of the protocol. Unknown event names are valid protocol messages.
86+func IsEventName(name string) bool {
87+ _, ok := eventNames[name]
88+ return ok
89+}
90+
91+// MessageID returns a fresh opaque id suitable for correlating a command and
92+// its result. UUIDs are used rather than a counter so ids remain unique when
93+// several supervisors or workers are running at once.
94+func NewMessageID() string { return uuid.NewString() }
95+
96+// IsMessageID reports whether id has the UUID form emitted by NewMessageID.
97+// Message ids are otherwise opaque on the wire, so readers do not require
98+// this form when accepting messages from a newer implementation.
99+func IsMessageID(id string) bool {
100+ if id == "" {
101+ return false
102+ }
103+ _, err := uuid.Parse(id)
104+ return err == nil
105+}
106+
107+// Envelope is one versioned JSONL command or event. Payload is deliberately a
108+// RawMessage: an old supervisor can retain an event it does not know about,
109+// including fields added inside that event's payload, and write it back
110+// without decoding it into a lossy map.
111+//
112+// The JSON representation of a current message is:
113+//
114+// {"version":1,"type":"...","message_id":"...","agent_id":"...",
115+// "turn_id":"...","timestamp":"...","payload":{...}}
116+//
117+// TurnID is omitted when it is empty. Unknown top-level fields are retained in
118+// Unknown.
119+type Envelope struct {
120+ Version int `json:"version"`
121+ Type string `json:"type"`
122+ MessageID string `json:"message_id,omitempty"`
123+ AgentID string `json:"agent_id,omitempty"`
124+ TurnID string `json:"turn_id,omitempty"`
125+ Timestamp time.Time `json:"timestamp"`
126+ Payload json.RawMessage `json:"payload"`
127+
128+ // Unknown contains unrecognised top-level fields from a versioned
129+ // envelope. Payload itself retains unrecognised payload fields.
130+ Unknown map[string]json.RawMessage `json:"-"`
131+
132+ // constructionErr records a payload-construction failure for the
133+ // compatibility-preserving value constructor. It is surfaced by Validate
134+ // and MarshalJSON rather than being turned into a valid null payload.
135+ constructionErr error
136+}
137+
138+// NewEnvelope creates a current-protocol envelope and assigns a message id
139+// and timestamp. payload may be any JSON-marshalable value. A nil payload is
140+// encoded as an empty object, which keeps command and event payloads uniform.
141+//
142+// For a marshalable payload this helper cannot fail. Call
143+// NewEnvelopeWithPayload when construction errors must be reported instead of
144+// being represented as a null payload.
145+func NewEnvelope(typ, agentID, turnID string, payload ...any) Envelope {
146+ e, err := NewEnvelopeWithPayload(typ, agentID, turnID, firstPayload(payload))
147+ if err == nil {
148+ return e
149+ }
150+ // Preserve the value-returning API used by command/event call sites, but
151+ // retain the construction failure so validation and wire encoding fail
152+ // instead of silently sending a null payload.
153+ return Envelope{
154+ Version: ProtocolVersion,
155+ Type: typ,
156+ MessageID: NewMessageID(),
157+ AgentID: agentID,
158+ TurnID: turnID,
159+ Timestamp: time.Now().UTC(),
160+ Payload: nil,
161+ constructionErr: fmt.Errorf("subagent protocol payload: %w", err),
162+ }
163+}
164+
165+// NewEnvelopeWithPayload is the checked form of NewEnvelope.
166+func NewEnvelopeWithPayload(typ, agentID, turnID string, payload any) (Envelope, error) {
167+ if strings.TrimSpace(typ) == "" {
168+ return Envelope{}, errors.New("subagent protocol: empty message type")
169+ }
170+ p, err := marshalPayload(payload)
171+ if err != nil {
172+ return Envelope{}, fmt.Errorf("subagent protocol payload: %w", err)
173+ }
174+ return Envelope{
175+ Version: ProtocolVersion,
176+ Type: typ,
177+ MessageID: NewMessageID(),
178+ AgentID: agentID,
179+ TurnID: turnID,
180+ Timestamp: time.Now().UTC(),
181+ Payload: p,
182+ }, nil
183+}
184+
185+// NewCommand creates a current-protocol command. Unknown command names are
186+// allowed so a bridge can forward commands introduced by a newer peer.
187+func NewCommand(name, agentID, turnID string, payload ...any) Envelope {
188+ return NewEnvelope(name, agentID, turnID, payload...)
189+}
190+
191+// NewEventEnvelope creates a current-protocol event. The name is not checked
192+// against the known event registry so unknown future events remain forward-compatible.
193+func NewEventEnvelope(name, agentID, turnID string, payload ...any) Envelope {
194+ return NewEnvelope(name, agentID, turnID, payload...)
195+}
196+
197+func firstPayload(payload []any) any {
198+ if len(payload) == 0 || payload[0] == nil {
199+ return map[string]any{}
200+ }
201+ return payload[0]
202+}
203+
204+func marshalPayload(payload any) (json.RawMessage, error) {
205+ if payload == nil {
206+ return json.RawMessage("{}"), nil
207+ }
208+ b, err := json.Marshal(payload)
209+ if err != nil {
210+ return nil, err
211+ }
212+ return append(json.RawMessage(nil), b...), nil
213+}
214+
215+// Validate checks the required fields of a current-protocol envelope. It is
216+// intentionally separate from ParseEnvelope: parsing an unknown future event
217+// should succeed, while a sender can opt into strict validation before write.
218+func (e Envelope) Validate() error {
219+ if e.constructionErr != nil {
220+ return e.constructionErr
221+ }
222+ if e.Version != ProtocolVersion {
223+ return fmt.Errorf("%w: %d", ErrUnsupportedProtocolVersion, e.Version)
224+ }
225+ if e.Type == "" {
226+ return errors.New("subagent protocol: missing type")
227+ }
228+ if e.MessageID == "" {
229+ return errors.New("subagent protocol: missing message_id")
230+ }
231+ if e.AgentID == "" {
232+ return errors.New("subagent protocol: missing agent_id")
233+ }
234+ if e.Timestamp.IsZero() {
235+ return errors.New("subagent protocol: missing timestamp")
236+ }
237+ if len(e.Payload) == 0 || !json.Valid(e.Payload) {
238+ return errors.New("subagent protocol: invalid payload")
239+ }
240+ return nil
241+}
242+
243+// IsCommand reports whether this envelope has a known command name.
244+func (e Envelope) IsCommand() bool { return IsCommandName(e.Type) }
245+
246+// IsEvent reports whether this envelope has a known event name.
247+func (e Envelope) IsEvent() bool { return IsEventName(e.Type) }
248+
249+// DecodePayload decodes the payload into dst without changing the preserved
250+// raw payload. This is the preferred way for a consumer to inspect a known
251+// event while still retaining it for a later forward-compatible write.
252+func (e Envelope) DecodePayload(dst any) error {
253+ if dst == nil {
254+ return errors.New("subagent protocol: nil payload destination")
255+ }
256+ if len(e.Payload) == 0 {
257+ return errors.New("subagent protocol: missing payload")
258+ }
259+ return json.Unmarshal(e.Payload, dst)
260+}
261+
262+// PayloadFields returns an object payload as raw fields. It returns an error
263+// for scalar or array payloads, rather than silently discarding their shape.
264+func (e Envelope) PayloadFields() (map[string]json.RawMessage, error) {
265+ var fields map[string]json.RawMessage
266+ if err := e.DecodePayload(&fields); err != nil {
267+ return nil, err
268+ }
269+ if fields == nil {
270+ return nil, errors.New("subagent protocol: payload is not an object")
271+ }
272+ return fields, nil
273+}
274+
275+// PayloadValue returns a decoded generic payload. PayloadFields is preferable
276+// when the caller needs to preserve exact nested JSON values.
277+func (e Envelope) PayloadValue() (any, error) {
278+ var value any
279+ if err := e.DecodePayload(&value); err != nil {
280+ return nil, err
281+ }
282+ return value, nil
283+}
284+
285+// SetPayload replaces the payload while keeping the envelope metadata and
286+// message id intact.
287+func (e *Envelope) SetPayload(payload any) error {
288+ if e == nil {
289+ return errors.New("subagent protocol: nil envelope")
290+ }
291+ p, err := marshalPayload(payload)
292+ if err != nil {
293+ return fmt.Errorf("subagent protocol payload: %w", err)
294+ }
295+ e.Payload = p
296+ return nil
297+}
298+
299+// ParseEnvelope parses one current-protocol envelope. Unknown event types and
300+// unknown payload fields are valid and retained.
301+func ParseEnvelope(data []byte) (Envelope, error) {
302+ data = bytes.TrimSpace(data)
303+ if len(data) == 0 {
304+ return Envelope{}, ErrEmptyProtocolLine
305+ }
306+ var raw map[string]json.RawMessage
307+ if err := json.Unmarshal(data, &raw); err != nil {
308+ return Envelope{}, fmt.Errorf("subagent protocol: decode envelope: %w", err)
309+ }
310+ if raw == nil {
311+ return Envelope{}, errors.New("subagent protocol: envelope must be an object")
312+ }
313+ var e Envelope
314+ if err := unmarshalVersionedEnvelope(raw, &e); err != nil {
315+ return Envelope{}, err
316+ }
317+ return e, nil
318+}
319+
320+// ParseJSONL parses one newline-delimited JSON message. It accepts a final
321+// line without a newline, as is customary for a stream after a clean EOF.
322+func ParseJSONL(line []byte) (Envelope, error) { return ParseEnvelope(line) }
323+
324+// ParseEvent parses a current-protocol JSON event. A known command is
325+// rejected; unknown types are accepted because an older peer cannot know
326+// whether a future event name is present in its event registry.
327+func ParseEvent(data []byte) (Envelope, error) {
328+ e, err := ParseEnvelope(data)
329+ if err != nil {
330+ return Envelope{}, err
331+ }
332+ if e.IsCommand() {
333+ return Envelope{}, ErrNotEvent
334+ }
335+ return e, nil
336+}
337+
338+// ParseCommand parses one current-protocol JSON command.
339+func ParseCommand(line string) (Envelope, error) {
340+ raw := strings.TrimSpace(line)
341+ if raw == "" {
342+ return Envelope{}, ErrEmptyProtocolLine
343+ }
344+ e, err := ParseEnvelope([]byte(raw))
345+ if err != nil {
346+ return Envelope{}, err
347+ }
348+ if IsEventName(e.Type) {
349+ return Envelope{}, ErrNotCommand
350+ }
351+ return e, nil
352+}
353+
354+// MarshalEnvelope returns one JSON object without a trailing newline.
355+func MarshalEnvelope(e Envelope) ([]byte, error) {
356+ return json.Marshal(e)
357+}
358+
359+// MarshalJSONL returns one newline-terminated JSONL message.
360+func MarshalJSONL(e Envelope) ([]byte, error) {
361+ b, err := MarshalEnvelope(e)
362+ if err != nil {
363+ return nil, err
364+ }
365+ return append(b, '\n'), nil
366+}
367+
368+// WriteEnvelope writes one complete newline-terminated JSONL message.
369+func WriteEnvelope(w io.Writer, e Envelope) error {
370+ if w == nil {
371+ return errors.New("subagent protocol: nil writer")
372+ }
373+ b, err := MarshalJSONL(e)
374+ if err != nil {
375+ return err
376+ }
377+ n, err := w.Write(b)
378+ if err == nil && n != len(b) {
379+ return io.ErrShortWrite
380+ }
381+ return err
382+}
383+
384+// ReadEnvelope reads exactly one JSONL message from a buffered reader. A
385+// caller reading many messages should reuse the same bufio.Reader.
386+func ReadEnvelope(r *bufio.Reader) (Envelope, error) {
387+ if r == nil {
388+ return Envelope{}, errors.New("subagent protocol: nil reader")
389+ }
390+ line, err := r.ReadBytes('\n')
391+ if len(bytes.TrimSpace(line)) == 0 && err == io.EOF {
392+ return Envelope{}, io.EOF
393+ }
394+ if len(line) != 0 {
395+ parsed, parseErr := ParseJSONL(line)
396+ if parseErr != nil {
397+ return Envelope{}, parseErr
398+ }
399+ return parsed, nil
400+ }
401+ return Envelope{}, err
402+}
403+
404+func (e Envelope) MarshalJSON() ([]byte, error) {
405+ return e.marshalVersionedJSON()
406+}
407+
408+func (e Envelope) marshalVersionedJSON() ([]byte, error) {
409+ if e.constructionErr != nil {
410+ return nil, e.constructionErr
411+ }
412+ version := e.Version
413+ if version == 0 {
414+ version = ProtocolVersion
415+ }
416+ messageID := e.MessageID
417+ if messageID == "" {
418+ messageID = NewMessageID()
419+ }
420+ timestamp := e.Timestamp
421+ if timestamp.IsZero() {
422+ timestamp = time.Now().UTC()
423+ }
424+ payload := e.Payload
425+ if len(payload) == 0 {
426+ payload = json.RawMessage("{}")
427+ }
428+ if !json.Valid(payload) {
429+ return nil, errors.New("subagent protocol: invalid payload JSON")
430+ }
431+ out := make(map[string]json.RawMessage, len(e.Unknown)+7)
432+ for key, value := range e.Unknown {
433+ if isEnvelopeField(key) {
434+ continue
435+ }
436+ out[key] = cloneRaw(value)
437+ }
438+ putRaw(out, "version", version)
439+ putRaw(out, "type", e.Type)
440+ putRaw(out, "message_id", messageID)
441+ putRaw(out, "agent_id", e.AgentID)
442+ if e.TurnID != "" {
443+ putRaw(out, "turn_id", e.TurnID)
444+ }
445+ putRaw(out, "timestamp", timestamp)
446+ out["payload"] = cloneRaw(payload)
447+ return json.Marshal(out)
448+}
449+
450+func (e *Envelope) UnmarshalJSON(data []byte) error {
451+ var raw map[string]json.RawMessage
452+ if err := json.Unmarshal(data, &raw); err != nil {
453+ return err
454+ }
455+ if raw == nil {
456+ return errors.New("subagent protocol: envelope must be an object")
457+ }
458+ var parsed Envelope
459+ if err := unmarshalVersionedEnvelope(raw, &parsed); err != nil {
460+ return err
461+ }
462+ *e = parsed
463+ return nil
464+}
465+
466+func unmarshalVersionedEnvelope(raw map[string]json.RawMessage, e *Envelope) error {
467+ if err := decodeRequired(raw, "version", &e.Version); err != nil {
468+ return err
469+ }
470+ if e.Version != ProtocolVersion {
471+ return fmt.Errorf("%w: %d", ErrUnsupportedProtocolVersion, e.Version)
472+ }
473+ if err := decodeRequired(raw, "type", &e.Type); err != nil {
474+ return err
475+ }
476+ if err := decodeOptionalString(raw, "message_id", &e.MessageID); err != nil {
477+ return err
478+ }
479+ if err := decodeOptionalString(raw, "agent_id", &e.AgentID); err != nil {
480+ return err
481+ }
482+ if err := decodeOptionalString(raw, "turn_id", &e.TurnID); err != nil {
483+ return err
484+ }
485+ if timestamp, ok := raw["timestamp"]; ok {
486+ if err := json.Unmarshal(timestamp, &e.Timestamp); err != nil {
487+ return fmt.Errorf("subagent protocol: timestamp: %w", err)
488+ }
489+ }
490+ if payload, ok := raw["payload"]; ok {
491+ e.Payload = cloneRaw(payload)
492+ } else {
493+ e.Payload = json.RawMessage("{}")
494+ }
495+ e.Unknown = unknownFields(raw, map[string]struct{}{
496+ "version": {}, "type": {}, "message_id": {}, "agent_id": {},
497+ "turn_id": {}, "timestamp": {}, "payload": {},
498+ })
499+ return nil
500+}
501+
502+func decodeRequired(raw map[string]json.RawMessage, name string, dst any) error {
503+ value, ok := raw[name]
504+ if !ok {
505+ return fmt.Errorf("subagent protocol: missing %s", name)
506+ }
507+ if err := json.Unmarshal(value, dst); err != nil {
508+ return fmt.Errorf("subagent protocol: %s: %w", name, err)
509+ }
510+ if name == "type" {
511+ if text, ok := dst.(*string); ok && strings.TrimSpace(*text) == "" {
512+ return errors.New("subagent protocol: empty message type")
513+ }
514+ }
515+ return nil
516+}
517+
518+func decodeOptionalString(raw map[string]json.RawMessage, name string, dst *string) error {
519+ value, ok := raw[name]
520+ if !ok {
521+ return nil
522+ }
523+ if err := json.Unmarshal(value, dst); err != nil {
524+ return fmt.Errorf("subagent protocol: %s: %w", name, err)
525+ }
526+ return nil
527+}
528+
529+func unknownFields(raw map[string]json.RawMessage, known map[string]struct{}) map[string]json.RawMessage {
530+ var out map[string]json.RawMessage
531+ for key, value := range raw {
532+ if _, ok := known[key]; ok {
533+ continue
534+ }
535+ if out == nil {
536+ out = make(map[string]json.RawMessage)
537+ }
538+ out[key] = cloneRaw(value)
539+ }
540+ return out
541+}
542+
543+func isEnvelopeField(key string) bool {
544+ switch key {
545+ case "version", "type", "message_id", "agent_id", "turn_id", "timestamp", "payload":
546+ return true
547+ default:
548+ return false
549+ }
550+}
551+
552+func putRaw(out map[string]json.RawMessage, key string, value any) {
553+ b, err := json.Marshal(value)
554+ if err == nil {
555+ out[key] = b
556+ }
557+}
558+
559+func cloneRaw(value json.RawMessage) json.RawMessage {
560+ return append(json.RawMessage(nil), value...)
561+}
562+
563+// The following small payload types document the stable fields of the
564+// protocol without forcing a receiver to use them. Receivers can always use
565+// Envelope.Payload to retain fields added in later versions.
566+type AgentPingPayload struct{}
567+type AgentShutdownPayload struct{}
568+type AgentReadyPayload struct {
569+ // Version is the worker/application version. WorkerVersion is the
570+ // unambiguous spelling for new producers.
571+ Version string `json:"version,omitempty"`
572+ WorkerVersion string `json:"worker_version,omitempty"`
573+ CWD string `json:"cwd,omitempty"`
574+ Model string `json:"model,omitempty"`
575+ Provider string `json:"provider,omitempty"`
576+}
577+type AgentHeartbeatPayload struct {
578+ Activity string `json:"activity,omitempty"`
579+}
580+type TurnStartPayload struct {
581+ Prompt string `json:"prompt"`
582+}
583+type TurnCancelPayload struct {
584+ Reason string `json:"reason,omitempty"`
585+}
586+type TurnProgressPayload struct {
587+ Text string `json:"text,omitempty"`
588+ Percent float64 `json:"percent,omitempty"`
589+ CurrentTool string `json:"current_tool,omitempty"`
590+}
591+type ToolStartedPayload struct {
592+ ToolID string `json:"tool_id,omitempty"`
593+ Name string `json:"name"`
594+}
595+type ToolFinishedPayload struct {
596+ ToolID string `json:"tool_id,omitempty"`
597+ Name string `json:"name,omitempty"`
598+ Error string `json:"error,omitempty"`
599+}
600+type MessageDeltaPayload struct {
601+ Text string `json:"text,omitempty"`
602+}
603+type ArtifactReference struct {
604+ Name string `json:"name,omitempty"`
605+ Ref string `json:"ref"`
606+ Mime string `json:"mime,omitempty"`
607+ Size int64 `json:"size,omitempty"`
608+}
609+type ProtocolError struct {
610+ Code string `json:"code,omitempty"`
611+ Message string `json:"message,omitempty"`
612+}
613+type TurnResultPayload struct {
614+ Status string `json:"status"`
615+ Summary string `json:"summary,omitempty"`
616+ Output string `json:"output,omitempty"`
617+ Structured json.RawMessage `json:"structured,omitempty"`
618+ Artifacts []ArtifactReference `json:"artifacts,omitempty"`
619+ ChangedFiles []string `json:"changed_files,omitempty"`
620+ Usage map[string]any `json:"usage,omitempty"`
621+ Error *ProtocolError `json:"error"`
622+}
623+type TurnFailedPayload struct {
624+ Error *ProtocolError `json:"error,omitempty"`
625+}
626+type AgentExitedPayload struct {
627+ Code int `json:"code,omitempty"`
628+ Reason string `json:"reason,omitempty"`
629+}
A packages/agent/subagents/protocol_test.go +285 -0
@@ -0,0 +1,285 @@
1+package subagents
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "testing"
10+ "time"
11+)
12+
13+func TestProtocolEnvelopeRoundTrip(t *testing.T) {
14+ e := NewEventEnvelope(EventTurnResult, "agent-123", "turn-4", map[string]any{
15+ "status": "succeeded",
16+ "output": "done",
17+ })
18+ if e.Version != ProtocolVersion {
19+ t.Fatalf("version = %d, want %d", e.Version, ProtocolVersion)
20+ }
21+ if e.MessageID == "" || !IsMessageID(e.MessageID) {
22+ t.Fatalf("message id = %q, want a UUID", e.MessageID)
23+ }
24+ if e.Timestamp.IsZero() {
25+ t.Fatal("constructor did not set timestamp")
26+ }
27+ if err := e.Validate(); err != nil {
28+ t.Fatalf("Validate: %v", err)
29+ }
30+
31+ line, err := MarshalJSONL(e)
32+ if err != nil {
33+ t.Fatalf("MarshalJSONL: %v", err)
34+ }
35+ if !bytes.HasSuffix(line, []byte{'\n'}) {
36+ t.Fatal("JSONL message is not newline terminated")
37+ }
38+ var wire map[string]any
39+ if err := json.Unmarshal(bytes.TrimSpace(line), &wire); err != nil {
40+ t.Fatalf("wire JSON: %v", err)
41+ }
42+ for _, field := range []string{"version", "type", "message_id", "agent_id", "turn_id", "timestamp", "payload"} {
43+ if _, ok := wire[field]; !ok {
44+ t.Errorf("wire message lacks %q", field)
45+ }
46+ }
47+
48+ got, err := ParseJSONL(line)
49+ if err != nil {
50+ t.Fatalf("ParseJSONL: %v", err)
51+ }
52+ if got.Version != e.Version || got.Type != e.Type || got.MessageID != e.MessageID ||
53+ got.AgentID != e.AgentID || got.TurnID != e.TurnID {
54+ t.Fatalf("metadata changed on round trip: got %+v, want %+v", got, e)
55+ }
56+ var result TurnResultPayload
57+ if err := got.DecodePayload(&result); err != nil {
58+ t.Fatalf("DecodePayload: %v", err)
59+ }
60+ if result.Status != "succeeded" || result.Output != "done" {
61+ t.Fatalf("decoded payload = %+v", result)
62+ }
63+}
64+
65+func TestProtocolPreservesUnknownEventAndPayload(t *testing.T) {
66+ input := `{"version":1,"type":"future.event","message_id":"msg-future","agent_id":"agent-1","turn_id":"turn-1","timestamp":"2026-08-04T12:00:00Z","future_top":{"enabled":true},"payload":{"known":"yes","future":{"nested":[1,2,{"x":true}]}}}`
67+ e, err := ParseEnvelope([]byte(input))
68+ if err != nil {
69+ t.Fatalf("ParseEnvelope: %v", err)
70+ }
71+ if e.Type != "future.event" {
72+ t.Fatalf("type = %q", e.Type)
73+ }
74+ if e.IsEvent() {
75+ t.Fatal("unknown event was reported as a known event")
76+ }
77+ if _, ok := e.Unknown["future_top"]; !ok {
78+ t.Fatalf("unknown top-level field was not retained: %#v", e.Unknown)
79+ }
80+ fields, err := e.PayloadFields()
81+ if err != nil {
82+ t.Fatalf("PayloadFields: %v", err)
83+ }
84+ if string(fields["future"]) != `{"nested":[1,2,{"x":true}]}` {
85+ t.Fatalf("unknown payload field changed: %s", fields["future"])
86+ }
87+
88+ encoded, err := MarshalEnvelope(e)
89+ if err != nil {
90+ t.Fatalf("MarshalEnvelope: %v", err)
91+ }
92+ var roundTripped map[string]any
93+ if err := json.Unmarshal(encoded, &roundTripped); err != nil {
94+ t.Fatalf("round-trip JSON: %v", err)
95+ }
96+ if _, ok := roundTripped["future_top"]; !ok {
97+ t.Fatal("unknown top-level field was dropped")
98+ }
99+ payload, ok := roundTripped["payload"].(map[string]any)
100+ if !ok || payload["future"] == nil {
101+ t.Fatalf("unknown payload was dropped: %#v", roundTripped["payload"])
102+ }
103+}
104+
105+func TestProtocolJSONCommandsAndDirectionChecks(t *testing.T) {
106+ command := NewCommand(CommandTurnStart, "agent-1", "turn-2", TurnStartPayload{Prompt: "hello"})
107+ line, err := MarshalJSONL(command)
108+ if err != nil {
109+ t.Fatalf("MarshalJSONL: %v", err)
110+ }
111+ got, err := ParseCommand(string(line))
112+ if err != nil {
113+ t.Fatalf("ParseCommand: %v", err)
114+ }
115+ if !got.IsCommand() || got.IsEvent() || got.Type != CommandTurnStart {
116+ t.Fatalf("direction = command:%v event:%v type:%q", got.IsCommand(), got.IsEvent(), got.Type)
117+ }
118+ if _, err := ParseEvent(line); !errors.Is(err, ErrNotEvent) {
119+ t.Fatalf("ParseEvent(command) = %v, want ErrNotEvent", err)
120+ }
121+
122+ event := NewEventEnvelope(EventAgentHeartbeat, "agent-1", "", AgentHeartbeatPayload{Activity: "idle"})
123+ eventLine, err := MarshalJSONL(event)
124+ if err != nil {
125+ t.Fatalf("Marshal event: %v", err)
126+ }
127+ if _, err := ParseCommand(string(eventLine)); !errors.Is(err, ErrNotCommand) {
128+ t.Fatalf("ParseCommand(event) = %v, want ErrNotCommand", err)
129+ }
130+}
131+
132+func TestProtocolStreamReadWrite(t *testing.T) {
133+ first := NewCommand(CommandAgentPing, "agent-1", "", nil)
134+ second := NewEventEnvelope(EventAgentReady, "agent-1", "", nil)
135+ var stream bytes.Buffer
136+ if err := WriteEnvelope(&stream, first); err != nil {
137+ t.Fatalf("write first: %v", err)
138+ }
139+ if err := WriteEnvelope(&stream, second); err != nil {
140+ t.Fatalf("write second: %v", err)
141+ }
142+ reader := bufio.NewReader(&stream)
143+ gotFirst, err := ReadEnvelope(reader)
144+ if err != nil {
145+ t.Fatalf("read first: %v", err)
146+ }
147+ gotSecond, err := ReadEnvelope(reader)
148+ if err != nil {
149+ t.Fatalf("read second: %v", err)
150+ }
151+ if gotFirst.Type != CommandAgentPing || gotSecond.Type != EventAgentReady {
152+ t.Fatalf("stream types = %q, %q", gotFirst.Type, gotSecond.Type)
153+ }
154+ if _, err := ReadEnvelope(reader); !errors.Is(err, io.EOF) {
155+ t.Fatalf("read after stream = %v, want EOF", err)
156+ }
157+}
158+
159+func TestProtocolMessageIDsAreUnique(t *testing.T) {
160+ seen := make(map[string]struct{}, 100)
161+ for i := 0; i < 100; i++ {
162+ id := NewMessageID()
163+ if !IsMessageID(id) {
164+ t.Fatalf("NewMessageID returned non-UUID %q", id)
165+ }
166+ if _, exists := seen[id]; exists {
167+ t.Fatalf("duplicate message id %q", id)
168+ }
169+ seen[id] = struct{}{}
170+ }
171+}
172+
173+func TestProtocolPayloadMutationKeepsMessageID(t *testing.T) {
174+ e := NewCommand(CommandTurnStart, "agent-1", "turn-1", TurnStartPayload{Prompt: "before"})
175+ id := e.MessageID
176+ if err := e.SetPayload(TurnStartPayload{Prompt: "after"}); err != nil {
177+ t.Fatalf("SetPayload: %v", err)
178+ }
179+ if e.MessageID != id {
180+ t.Fatalf("SetPayload changed message id from %q to %q", id, e.MessageID)
181+ }
182+}
183+
184+func TestProtocolUnsupportedVersion(t *testing.T) {
185+ _, err := ParseEnvelope([]byte(`{"version":2,"type":"future.event","message_id":"m","agent_id":"a","timestamp":"2026-08-04T12:00:00Z","payload":{}}`))
186+ if !errors.Is(err, ErrUnsupportedProtocolVersion) {
187+ t.Fatalf("ParseEnvelope version 2 = %v, want unsupported version", err)
188+ }
189+}
190+
191+func TestProtocolEmptyAndMalformedInputs(t *testing.T) {
192+ for _, input := range []string{"", " ", "not a command"} {
193+ if _, err := ParseCommand(input); err == nil {
194+ t.Errorf("ParseCommand(%q) succeeded", input)
195+ }
196+ }
197+ if _, err := ParseEnvelope([]byte(`{"payload":{}}`)); err == nil {
198+ t.Fatal("ParseEnvelope without type succeeded")
199+ }
200+ if _, err := ParseEnvelope([]byte(`{"version":1,"type":"x","payload":`)); err == nil {
201+ t.Fatal("ParseEnvelope malformed JSON succeeded")
202+ }
203+
204+ invalid := NewEnvelope("x", "agent-1", "turn-1", func() {})
205+ if err := invalid.Validate(); err == nil {
206+ t.Fatal("NewEnvelope accepted an unmarshalable payload")
207+ }
208+ if _, err := MarshalEnvelope(invalid); err == nil {
209+ t.Fatal("MarshalEnvelope accepted an unmarshalable payload")
210+ }
211+
212+ zeroVersion := Envelope{
213+ Type: "x",
214+ MessageID: "message-1",
215+ AgentID: "agent-1",
216+ Timestamp: time.Now().UTC(),
217+ Payload: json.RawMessage(`{}`),
218+ }
219+ wire, err := MarshalEnvelope(zeroVersion)
220+ if err != nil {
221+ t.Fatalf("MarshalEnvelope zero version: %v", err)
222+ }
223+ var fields map[string]json.RawMessage
224+ if err := json.Unmarshal(wire, &fields); err != nil {
225+ t.Fatalf("decode zero-version envelope: %v", err)
226+ }
227+ var version int
228+ if err := json.Unmarshal(fields["version"], &version); err != nil {
229+ t.Fatalf("decode version: %v", err)
230+ }
231+ if version != ProtocolVersion {
232+ t.Fatalf("version = %d, want %d", version, ProtocolVersion)
233+ }
234+}
235+
236+func TestProtocolResultShape(t *testing.T) {
237+ result := TurnResultPayload{
238+ Status: "succeeded",
239+ Summary: "implemented parser",
240+ Output: "full output",
241+ Structured: json.RawMessage(`{"ok":true}`),
242+ Artifacts: []ArtifactReference{{Name: "patch", Ref: "subagent://a/patch"}},
243+ ChangedFiles: []string{"protocol.go"},
244+ Usage: map[string]any{"input_tokens": 3},
245+ }
246+ e := NewEventEnvelope(EventTurnResult, "a", "t", result)
247+ var decoded TurnResultPayload
248+ if err := e.DecodePayload(&decoded); err != nil {
249+ t.Fatalf("DecodePayload: %v", err)
250+ }
251+ if decoded.Status != "succeeded" || len(decoded.Artifacts) != 1 || decoded.Artifacts[0].Ref != "subagent://a/patch" {
252+ t.Fatalf("decoded result = %+v", decoded)
253+ }
254+}
255+
256+func TestProtocolTimestampIsRFC3339(t *testing.T) {
257+ e := NewEventEnvelope(EventAgentReady, "a", "", nil)
258+ wire, err := MarshalEnvelope(e)
259+ if err != nil {
260+ t.Fatalf("MarshalEnvelope: %v", err)
261+ }
262+ var fields map[string]json.RawMessage
263+ if err := json.Unmarshal(wire, &fields); err != nil {
264+ t.Fatal(err)
265+ }
266+ var timestamp string
267+ if err := json.Unmarshal(fields["timestamp"], &timestamp); err != nil {
268+ t.Fatal(err)
269+ }
270+ if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil {
271+ t.Fatalf("timestamp %q is not RFC3339Nano: %v", timestamp, err)
272+ }
273+}
274+
275+func TestProtocolKnownNames(t *testing.T) {
276+ if !IsCommandName(CommandAgentPing) || !IsCommandName(CommandTurnStart) || !IsCommandName(CommandTurnCancel) || !IsCommandName(CommandAgentShutdown) {
277+ t.Fatal("one or more canonical command names are not registered")
278+ }
279+ if !IsEventName(EventAgentReady) || !IsEventName(EventAgentHeartbeat) || !IsEventName(EventTurnStarted) || !IsEventName(EventTurnProgress) || !IsEventName(EventToolStarted) || !IsEventName(EventToolFinished) || !IsEventName(EventMessageDelta) || !IsEventName(EventTurnResult) || !IsEventName(EventTurnFailed) || !IsEventName(EventAgentIdle) || !IsEventName(EventAgentExited) {
280+ t.Fatal("one or more canonical event names are not registered")
281+ }
282+ if IsEventName("future.event") || IsCommandName("future.command") {
283+ t.Fatal("future names should not be reported as known names")
284+ }
285+}
A packages/agent/subagents/result.go +584 -0
@@ -0,0 +1,584 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "time"
13+ "unicode/utf8"
14+)
15+
16+// TurnStatus is the durable outcome of one delegated turn.
17+type TurnStatus string
18+
19+const (
20+ ResultSucceeded TurnStatus = "succeeded"
21+ ResultFailed TurnStatus = "failed"
22+ ResultCanceled TurnStatus = "canceled"
23+)
24+
25+// ArtifactRef points to a durable file without exposing the supervisor's
26+// local state layout to callers.
27+type ArtifactRef struct {
28+ Name string `json:"name,omitempty"`
29+ Ref string `json:"ref"`
30+ MediaType string `json:"media_type,omitempty"`
31+ Size int64 `json:"size,omitempty"`
32+}
33+
34+// ResultError is intentionally free of credentials and argv details.
35+type ResultError struct {
36+ Code string `json:"code,omitempty"`
37+ Message string `json:"message,omitempty"`
38+}
39+
40+// TurnResult is the protocol-level result for a delegated turn. Output is a
41+// bounded inline preview; the full session/history remains available through
42+// HistoryRef and the complete JSON result through ResultRef.
43+type TurnResult struct {
44+ Version int `json:"version"`
45+ AgentID string `json:"agent_id,omitempty"`
46+ TurnID string `json:"turn_id,omitempty"`
47+ Status TurnStatus `json:"status"`
48+ Summary string `json:"summary,omitempty"`
49+ Output string `json:"output,omitempty"`
50+ Structured json.RawMessage `json:"structured,omitempty"`
51+ Artifacts []ArtifactRef `json:"artifacts,omitempty"`
52+ ChangedFiles []string `json:"changed_files,omitempty"`
53+ Usage map[string]any `json:"usage,omitempty"`
54+ Error *ResultError `json:"error,omitempty"`
55+ CreatedAt time.Time `json:"created_at"`
56+}
57+
58+func (r *TurnResult) Validate(maxBytes, maxLines int) error {
59+ if r == nil {
60+ return errors.New("subagents: nil turn result")
61+ }
62+ if r.Version == 0 {
63+ r.Version = ProtocolVersion
64+ }
65+ if r.Version != ProtocolVersion {
66+ return fmt.Errorf("subagents: unsupported result version %d", r.Version)
67+ }
68+ if r.Status != ResultSucceeded && r.Status != ResultFailed && r.Status != ResultCanceled {
69+ return fmt.Errorf("subagents: invalid result status %q", r.Status)
70+ }
71+ if r.AgentID == "" {
72+ return errors.New("subagents: result missing agent id")
73+ }
74+ if r.TurnID == "" {
75+ return errors.New("subagents: result missing turn id")
76+ }
77+ if len(r.Structured) > 0 && !json.Valid(r.Structured) {
78+ return errors.New("subagents: result structured payload is invalid JSON")
79+ }
80+ if maxBytes > 0 && len([]byte(r.Output)) > maxBytes {
81+ return fmt.Errorf("subagents: result output exceeds %d bytes", maxBytes)
82+ }
83+ if maxLines > 0 && countLines(r.Output) > maxLines {
84+ return fmt.Errorf("subagents: result output exceeds %d lines", maxLines)
85+ }
86+ return nil
87+}
88+
89+// Bounded returns a copy with inline output capped by bytes and lines. It
90+// never mutates the durable result's caller-owned value.
91+func (r *TurnResult) Bounded(maxBytes, maxLines int) *TurnResult {
92+ out := cloneTurnResult(r)
93+ if out == nil {
94+ return nil
95+ }
96+ if len([]byte(out.Summary)) > 4*1024 {
97+ out.Summary = truncateUTF8(out.Summary, 4*1024)
98+ }
99+ out.Output = boundInlineText(out.Output, maxBytes, maxLines)
100+ return out
101+}
102+
103+// boundInlineText applies the shared line, byte, and UTF-8-safe bounds used
104+// for inline assistant output and durable turn-result previews.
105+func boundInlineText(value string, maxBytes, maxLines int) string {
106+ const marker = "...[output truncated]"
107+ if maxLines > 0 {
108+ lines := strings.Split(value, "\n")
109+ if len(lines) > maxLines {
110+ if maxLines == 1 {
111+ value = marker
112+ } else {
113+ value = strings.Join(lines[:maxLines-1], "\n") + "\n" + marker
114+ }
115+ }
116+ }
117+ if maxBytes > 0 && len([]byte(value)) > maxBytes {
118+ if maxBytes <= len(marker) {
119+ return truncateUTF8(value, maxBytes)
120+ }
121+ return truncateUTF8(value, maxBytes-len(marker)) + marker
122+ }
123+ return value
124+}
125+
126+func truncateUTF8(value string, maxBytes int) string {
127+ if maxBytes <= 0 {
128+ return ""
129+ }
130+ data := []byte(value)
131+ if len(data) <= maxBytes {
132+ return value
133+ }
134+
135+ cut := maxBytes
136+ // Only inspect the short suffix that can belong to a UTF-8 rune ending
137+ // at the cut. Validating the entire prefix would make this quadratic for
138+ // invalid input and would incorrectly discard valid bytes before an
139+ // earlier invalid byte.
140+ start := cut - 1
141+ minimum := cut - utf8.UTFMax
142+ if minimum < 0 {
143+ minimum = 0
144+ }
145+ for start >= minimum && isUTF8Continuation(data[start]) {
146+ start--
147+ }
148+ if start >= minimum {
149+ if size := utf8LeadSize(data[start]); size > cut-start && isUTF8Prefix(data[start:cut], size) {
150+ cut = start
151+ }
152+ }
153+ return string(data[:cut])
154+}
155+
156+func isUTF8Continuation(b byte) bool {
157+ return b&0xc0 == 0x80
158+}
159+
160+func utf8LeadSize(b byte) int {
161+ switch {
162+ case b < utf8.RuneSelf:
163+ return 1
164+ case b >= 0xc2 && b <= 0xdf:
165+ return 2
166+ case b >= 0xe0 && b <= 0xef:
167+ return 3
168+ case b >= 0xf0 && b <= 0xf4:
169+ return 4
170+ default:
171+ return 0
172+ }
173+}
174+
175+func isUTF8Prefix(data []byte, size int) bool {
176+ if len(data) <= 1 {
177+ return true
178+ }
179+ if !isUTF8Continuation(data[1]) {
180+ return false
181+ }
182+ switch data[0] {
183+ case 0xe0:
184+ if data[1] < 0xa0 {
185+ return false
186+ }
187+ case 0xed:
188+ if data[1] > 0x9f {
189+ return false
190+ }
191+ case 0xf0:
192+ if data[1] < 0x90 {
193+ return false
194+ }
195+ case 0xf4:
196+ if data[1] > 0x8f {
197+ return false
198+ }
199+ }
200+ for _, b := range data[2:] {
201+ if !isUTF8Continuation(b) {
202+ return false
203+ }
204+ }
205+ return len(data) < size
206+}
207+
208+func validateTurnResultAgent(result *TurnResult, agentID string) error {
209+ if result == nil {
210+ return errors.New("subagents: nil turn result")
211+ }
212+ if agentID != "" && result.AgentID != agentID {
213+ return fmt.Errorf("subagents: turn result belongs to agent %q, want %q", result.AgentID, agentID)
214+ }
215+ return nil
216+}
217+
218+func decodeTurnResultEvent(ev Event, agentID string, maxBytes, maxLines int) (*TurnResult, error) {
219+ data, err := json.Marshal(ev.Data)
220+ if err != nil {
221+ return nil, fmt.Errorf("subagents: encode turn result event: %w", err)
222+ }
223+ var result TurnResult
224+ if err := json.Unmarshal(data, &result); err != nil {
225+ return nil, fmt.Errorf("subagents: decode turn result event: %w", err)
226+ }
227+ if result.AgentID == "" {
228+ result.AgentID = firstNonEmpty(ev.AgentID, agentID)
229+ }
230+ if err := validateTurnResultAgent(&result, agentID); err != nil {
231+ return nil, err
232+ }
233+ if result.TurnID == "" {
234+ result.TurnID = ev.TurnID
235+ }
236+ if result.Version == 0 {
237+ result.Version = ProtocolVersion
238+ }
239+ if result.CreatedAt.IsZero() {
240+ result.CreatedAt = ev.Time
241+ if result.CreatedAt.IsZero() {
242+ result.CreatedAt = time.Now().UTC()
243+ }
244+ }
245+ resultPtr := result.Bounded(maxBytes, maxLines)
246+ if err := resultPtr.Validate(0, 0); err != nil {
247+ return nil, err
248+ }
249+ return resultPtr, nil
250+}
251+
252+func cloneTurnResult(r *TurnResult) *TurnResult {
253+ if r == nil {
254+ return nil
255+ }
256+ copy := *r
257+ copy.Structured = append(json.RawMessage(nil), r.Structured...)
258+ copy.Artifacts = append([]ArtifactRef(nil), r.Artifacts...)
259+ copy.ChangedFiles = append([]string(nil), r.ChangedFiles...)
260+ if r.Usage != nil {
261+ copy.Usage = make(map[string]any, len(r.Usage))
262+ for key, value := range r.Usage {
263+ copy.Usage[key] = value
264+ }
265+ }
266+ if r.Error != nil {
267+ errCopy := *r.Error
268+ copy.Error = &errCopy
269+ }
270+ return &copy
271+}
272+
273+func countLines(s string) int {
274+ if s == "" {
275+ return 0
276+ }
277+ return 1 + strings.Count(s, "\n")
278+}
279+
280+func AgentRef(id string) string { return "subagent://" + id }
281+func HistoryRef(id string) string { return AgentRef(id) + "/history" }
282+func ResultRef(id string) string { return AgentRef(id) + "/result" }
283+func PatchRef(id string) string { return AgentRef(id) + "/patch" }
284+
285+const maxResultFileBytes = 8 * 1024 * 1024
286+
287+const (
288+ workspaceCaptureErrorCode = "workspace_capture_failed"
289+ workspaceCaptureErrorMessage = "workspace capture failed"
290+)
291+
292+func resultPath(stateDir string) string { return filepath.Join(stateDir, "result.json") }
293+func patchPath(stateDir string) string { return filepath.Join(stateDir, "patch.diff") }
294+
295+// writeSyncedFile writes a complete temporary file and closes it after the
296+// contents have been synced. Keeping this separate makes it harder for an
297+// atomic rename caller to accidentally publish an unflushed or open file.
298+func writeSyncedFile(path string, data []byte, perm os.FileMode) (err error) {
299+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
300+ if err != nil {
301+ return fmt.Errorf("open: %w", err)
302+ }
303+ defer func() {
304+ closeErr := file.Close()
305+ if closeErr == nil {
306+ return
307+ }
308+ closeErr = fmt.Errorf("close: %w", closeErr)
309+ if err == nil {
310+ err = closeErr
311+ } else {
312+ err = errors.Join(err, closeErr)
313+ }
314+ }()
315+
316+ for written := 0; written < len(data); {
317+ n, writeErr := file.Write(data[written:])
318+ written += n
319+ if writeErr != nil {
320+ return fmt.Errorf("write: %w", writeErr)
321+ }
322+ if n == 0 {
323+ return fmt.Errorf("write: %w", io.ErrShortWrite)
324+ }
325+ }
326+ if err := file.Sync(); err != nil {
327+ return fmt.Errorf("sync: %w", err)
328+ }
329+ return nil
330+}
331+
332+func writeTurnResult(stateDir string, result *TurnResult) (err error) {
333+ if result == nil {
334+ return errors.New("subagents: nil result")
335+ }
336+ if result.CreatedAt.IsZero() {
337+ result.CreatedAt = time.Now().UTC()
338+ }
339+ if result.Version == 0 {
340+ result.Version = ProtocolVersion
341+ }
342+ if err := result.Validate(0, 0); err != nil {
343+ return err
344+ }
345+ data, err := json.MarshalIndent(result, "", " ")
346+ if err != nil {
347+ return fmt.Errorf("subagents result marshal: %w", err)
348+ }
349+ if len(data) > maxResultFileBytes {
350+ return fmt.Errorf("subagents result exceeds %d bytes", maxResultFileBytes)
351+ }
352+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
353+ return fmt.Errorf("subagents result dir: %w", err)
354+ }
355+ tmp := resultPath(stateDir) + ".tmp"
356+ defer func() {
357+ if err != nil {
358+ _ = os.Remove(tmp)
359+ }
360+ }()
361+ if err := writeSyncedFile(tmp, append(data, '\n'), 0o600); err != nil {
362+ return fmt.Errorf("subagents result write: %w", err)
363+ }
364+ if err := os.Rename(tmp, resultPath(stateDir)); err != nil {
365+ return fmt.Errorf("subagents result rename: %w", err)
366+ }
367+ return nil
368+}
369+
370+func (f *Supervisor) captureWorkspace(a *Agent) {
371+ if a == nil || a.workspaceCapture == nil {
372+ return
373+ }
374+ stateDir := a.stateDirectory(f.cfg.Root)
375+ if _, err := os.Stat(stateDir); err != nil {
376+ recordWorkspaceCaptureFailure(a)
377+ return
378+ }
379+ capture, err := a.workspaceCapture()
380+ if err != nil {
381+ recordWorkspaceCaptureFailure(a)
382+ return
383+ }
384+ a.lifecycleMu.Lock()
385+ a.changedFiles = append([]string(nil), capture.ChangedFiles...)
386+ a.lifecycleMu.Unlock()
387+ if len(capture.Patch) == 0 {
388+ return
389+ }
390+ path := patchPath(stateDir)
391+ tmp := path + ".tmp"
392+ if err := writeSyncedFile(tmp, capture.Patch, 0o600); err != nil {
393+ _ = os.Remove(tmp)
394+ recordWorkspaceCaptureFailure(a)
395+ return
396+ }
397+ if err := os.Rename(tmp, path); err != nil {
398+ _ = os.Remove(tmp)
399+ recordWorkspaceCaptureFailure(a)
400+ return
401+ }
402+ a.lifecycleMu.Lock()
403+ a.patchRef = PatchRef(a.ID)
404+ a.lifecycleMu.Unlock()
405+}
406+
407+// recordWorkspaceCaptureFailure records a stable, non-sensitive error in the
408+// result already received from the child (or creates the durable fallback).
409+// The underlying filesystem/capture error can contain private paths or data,
410+// so it is deliberately not copied into ResultError.
411+func recordWorkspaceCaptureFailure(a *Agent) {
412+ if a == nil {
413+ return
414+ }
415+ result := a.Result()
416+ if result == nil || validateTurnResultAgent(result, a.ID) != nil {
417+ result = &TurnResult{AgentID: a.ID}
418+ }
419+ result.Version = ProtocolVersion
420+ if result.AgentID == "" {
421+ result.AgentID = a.ID
422+ }
423+ if result.TurnID == "" {
424+ turnID := a.CurrentTurnID()
425+ if turnID == "" {
426+ turnID = fmt.Sprintf("turn-%d", a.AttemptValue())
427+ }
428+ result.TurnID = turnID
429+ }
430+ if result.CreatedAt.IsZero() {
431+ result.CreatedAt = time.Now().UTC()
432+ }
433+ result.Status = ResultFailed
434+ result.Error = &ResultError{
435+ Code: workspaceCaptureErrorCode,
436+ Message: workspaceCaptureErrorMessage,
437+ }
438+ a.setResult(result)
439+}
440+
441+func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
442+ if a == nil {
443+ return
444+ }
445+ attempt := a.AttemptValue()
446+ result := a.Result()
447+ if result != nil {
448+ if err := validateTurnResultAgent(result, a.ID); err != nil {
449+ result = nil
450+ }
451+ }
452+ if result != nil {
453+ // A child may omit agent/turn metadata in a result event; the
454+ // supervisor supplies it before validating the durable fallback.
455+ if result.AgentID == "" {
456+ result.AgentID = a.ID
457+ }
458+ if result.TurnID == "" {
459+ result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", attempt))
460+ }
461+ if result.Version == 0 {
462+ result.Version = ProtocolVersion
463+ }
464+ if result.CreatedAt.IsZero() {
465+ result.CreatedAt = time.Now().UTC()
466+ }
467+ if err := result.Validate(0, 0); err != nil {
468+ result = nil
469+ }
470+ }
471+ if result == nil {
472+ turnID := a.CurrentTurnID()
473+ if turnID == "" {
474+ turnID = fmt.Sprintf("turn-%d", attempt)
475+ }
476+ a.mu.Lock()
477+ output := a.lastAssistant
478+ a.mu.Unlock()
479+ resultStatus := ResultSucceeded
480+ switch {
481+ case status == StatusFailed && errors.Is(runErr, context.Canceled):
482+ resultStatus = ResultCanceled
483+ case status == StatusFailed:
484+ resultStatus = ResultFailed
485+ case status == StatusKilled:
486+ resultStatus = ResultCanceled
487+ }
488+ result = &TurnResult{
489+ Version: ProtocolVersion,
490+ AgentID: a.ID,
491+ TurnID: turnID,
492+ Status: resultStatus,
493+ Output: output,
494+ CreatedAt: time.Now().UTC(),
495+ }
496+ }
497+ if result.AgentID == "" {
498+ result.AgentID = a.ID
499+ }
500+ if result.TurnID == "" {
501+ result.TurnID = firstNonEmpty(a.CurrentTurnID(), fmt.Sprintf("turn-%d", attempt))
502+ }
503+ if result.Version == 0 {
504+ result.Version = ProtocolVersion
505+ }
506+ if result.CreatedAt.IsZero() {
507+ result.CreatedAt = time.Now().UTC()
508+ }
509+ if runErr != nil {
510+ if status == StatusKilled || errors.Is(runErr, context.Canceled) {
511+ result.Status = ResultCanceled
512+ } else {
513+ result.Status = ResultFailed
514+ }
515+ if result.Error == nil {
516+ result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}
517+ }
518+ }
519+ if len(result.ChangedFiles) == 0 {
520+ a.lifecycleMu.Lock()
521+ result.ChangedFiles = append([]string(nil), a.changedFiles...)
522+ a.lifecycleMu.Unlock()
523+ }
524+ if result.Summary == "" && result.Output != "" {
525+ result.Summary = strings.Split(strings.TrimSpace(result.Output), "\n")[0]
526+ }
527+ result = result.Bounded(a.maxOutputBytes, a.maxOutputLines)
528+ a.setResult(result)
529+ stateDir := a.stateDirectory(f.cfg.Root)
530+ if err := writeTurnResult(stateDir, result); err == nil {
531+ a.lifecycleMu.Lock()
532+ a.resultRef = ResultRef(a.ID)
533+ a.lifecycleMu.Unlock()
534+ }
535+}
536+
537+// ReadResult returns the durable structured result for an agent.
538+func (f *Supervisor) ReadResult(id string) (*TurnResult, error) {
539+ a := f.Get(id)
540+ if a == nil {
541+ return nil, fmt.Errorf("subagents: no such agent %q", id)
542+ }
543+ if result := a.Result(); result != nil {
544+ if err := validateTurnResultAgent(result, a.ID); err != nil {
545+ return nil, err
546+ }
547+ return result, nil
548+ }
549+ result, err := readTurnResult(a.stateDirectory(f.cfg.Root))
550+ if err != nil {
551+ return nil, err
552+ }
553+ if err := validateTurnResultAgent(result, a.ID); err != nil {
554+ return nil, err
555+ }
556+ return result.Bounded(f.cfg.Policy.MaxOutputBytes, f.cfg.Policy.MaxOutputLines), nil
557+}
558+
559+func (f *Supervisor) ResultReference(id string) string { return ResultRef(id) }
560+func (f *Supervisor) HistoryReference(id string) string { return HistoryRef(id) }
561+func (f *Supervisor) PatchReference(id string) string { return PatchRef(id) }
562+
563+func readTurnResult(stateDir string) (*TurnResult, error) {
564+ file, err := os.Open(resultPath(stateDir))
565+ if err != nil {
566+ return nil, err
567+ }
568+ defer file.Close()
569+ data, err := io.ReadAll(io.LimitReader(file, maxResultFileBytes+1))
570+ if err != nil {
571+ return nil, err
572+ }
573+ if len(data) > maxResultFileBytes {
574+ return nil, fmt.Errorf("subagents result exceeds %d bytes", maxResultFileBytes)
575+ }
576+ var result TurnResult
577+ if err := json.Unmarshal(data, &result); err != nil {
578+ return nil, fmt.Errorf("subagents result parse: %w", err)
579+ }
580+ if err := result.Validate(0, 0); err != nil {
581+ return nil, err
582+ }
583+ return &result, nil
584+}
A packages/agent/subagents/result_test.go +309 -0
@@ -0,0 +1,309 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "runtime"
10+ "strings"
11+ "testing"
12+ "unicode/utf8"
13+)
14+
15+func TestTurnResultValidationAndBoundedOutput(t *testing.T) {
16+ result := &TurnResult{
17+ Version: ProtocolVersion,
18+ AgentID: "agent-1",
19+ TurnID: "turn-1",
20+ Status: ResultSucceeded,
21+ Output: "one\ntwo\nthree",
22+ Structured: json.RawMessage(`{"ok":true}`),
23+ }
24+ if err := result.Validate(100, 3); err != nil {
25+ t.Fatal(err)
26+ }
27+ bounded := result.Bounded(32, 2)
28+ if bounded == result || !strings.Contains(bounded.Output, "truncated") {
29+ t.Fatalf("bounded result = %#v", bounded)
30+ }
31+ if result.Output != "one\ntwo\nthree" {
32+ t.Fatal("Bounded mutated the original result")
33+ }
34+}
35+
36+func TestTurnResultBoundedHonorsLineAndUTF8Limits(t *testing.T) {
37+ result := &TurnResult{
38+ Version: ProtocolVersion, AgentID: "a", TurnID: "t", Status: ResultSucceeded,
39+ Output: "😀\n第二行\n第三行",
40+ }
41+ bounded := result.Bounded(10, 2)
42+ if countLines(bounded.Output) > 2 {
43+ t.Fatalf("bounded lines = %d: %q", countLines(bounded.Output), bounded.Output)
44+ }
45+ if len([]byte(bounded.Output)) > 10 {
46+ t.Fatalf("bounded bytes = %d: %q", len([]byte(bounded.Output)), bounded.Output)
47+ }
48+ if !utf8.ValidString(bounded.Output) {
49+ t.Fatal("bounded output is not valid UTF-8")
50+ }
51+}
52+
53+func TestTruncateUTF8CutsOnlyIncompleteTrailingRune(t *testing.T) {
54+ invalidPrefix := string([]byte{0xff, 'a', 'b', 'c'})
55+ tests := []struct {
56+ name string
57+ value string
58+ maxBytes int
59+ want string
60+ }{
61+ {
62+ name: "multibyte",
63+ value: "prefix😀suffix",
64+ maxBytes: len([]byte("prefix")) + 2,
65+ want: "prefix",
66+ },
67+ {
68+ name: "invalid byte before incomplete rune",
69+ value: invalidPrefix + string([]byte{0xe2, 0x82}) + "tail",
70+ maxBytes: len([]byte(invalidPrefix)) + 2,
71+ want: invalidPrefix,
72+ },
73+ {
74+ name: "invalid byte before ascii cut",
75+ value: invalidPrefix + "😀",
76+ maxBytes: len([]byte(invalidPrefix)),
77+ want: invalidPrefix,
78+ },
79+ {
80+ name: "invalid trailing byte is preserved",
81+ value: string([]byte{'a', 0xff, 0x80}) + "😀",
82+ maxBytes: 3,
83+ want: string([]byte{'a', 0xff, 0x80}),
84+ },
85+ }
86+ for _, tt := range tests {
87+ t.Run(tt.name, func(t *testing.T) {
88+ if got := truncateUTF8(tt.value, tt.maxBytes); got != tt.want {
89+ t.Fatalf("truncateUTF8() = %q (%x), want %q (%x)", got, got, tt.want, tt.want)
90+ }
91+ })
92+ }
93+}
94+
95+func TestDecodeTurnResultEventValidatesIdentityAndStatus(t *testing.T) {
96+ invalid := NewEvent(EventTurnResult, map[string]any{
97+ "status": "unknown",
98+ "turn_id": "turn-1",
99+ })
100+ if _, err := decodeTurnResultEvent(invalid, "agent-1", 100, 10); err == nil {
101+ t.Fatal("invalid result status was accepted")
102+ }
103+ mismatched := NewEvent(EventTurnResult, map[string]any{
104+ "agent_id": "other",
105+ "status": string(ResultSucceeded),
106+ "turn_id": "turn-1",
107+ })
108+ if _, err := decodeTurnResultEvent(mismatched, "agent-1", 100, 10); err == nil {
109+ t.Fatal("mismatched result agent was accepted")
110+ }
111+ valid := NewEvent(EventTurnResult, map[string]any{"status": string(ResultSucceeded)})
112+ valid.AgentID = "agent-1"
113+ valid.TurnID = "turn-1"
114+ result, err := decodeTurnResultEvent(valid, "agent-1", 100, 10)
115+ if err != nil || result.AgentID != "agent-1" || result.TurnID != "turn-1" {
116+ t.Fatalf("valid result = %#v, err=%v", result, err)
117+ }
118+}
119+
120+func TestTurnResultReferencesAndPersistence(t *testing.T) {
121+ if got := AgentRef("a"); got != "subagent://a" {
122+ t.Fatal(got)
123+ }
124+ if got := HistoryRef("a"); got != "subagent://a/history" {
125+ t.Fatal(got)
126+ }
127+ if got := ResultRef("a"); got != "subagent://a/result" {
128+ t.Fatal(got)
129+ }
130+ if got := PatchRef("a"); got != "subagent://a/patch" {
131+ t.Fatal(got)
132+ }
133+ state := t.TempDir()
134+ want := &TurnResult{AgentID: "a", TurnID: "t", Status: ResultFailed, Error: &ResultError{Code: "x", Message: "failed"}}
135+ if err := writeTurnResult(state, want); err != nil {
136+ t.Fatal(err)
137+ }
138+ got, err := readTurnResult(state)
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+ if got.Status != want.Status || got.Error == nil || got.Error.Code != "x" {
143+ t.Fatalf("got = %#v", got)
144+ }
145+ info, err := os.Stat(filepath.Join(state, "result.json"))
146+ if err != nil {
147+ t.Fatal(err)
148+ }
149+ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
150+ t.Fatalf("result permissions = %o, want 600", info.Mode().Perm())
151+ }
152+}
153+
154+func TestWriteTurnResultCleansTemporaryFileAfterRenameFailure(t *testing.T) {
155+ state := t.TempDir()
156+ if err := os.Mkdir(resultPath(state), 0o700); err != nil {
157+ t.Fatal(err)
158+ }
159+ result := &TurnResult{AgentID: "a", TurnID: "t", Status: ResultSucceeded}
160+ if err := writeTurnResult(state, result); err == nil {
161+ t.Fatal("writeTurnResult succeeded when the result path was a directory")
162+ }
163+ if _, err := os.Stat(resultPath(state) + ".tmp"); !os.IsNotExist(err) {
164+ t.Fatalf("temporary result file still exists, stat error = %v", err)
165+ }
166+}
167+
168+func TestCaptureWorkspaceFailureIsDurableAndPrivate(t *testing.T) {
169+ makeAgent := func(stateDir string, capture func() (WorkspaceCapture, error)) *Agent {
170+ return &Agent{ID: "agent-1", stateDir: stateDir, workspaceCapture: capture}
171+ }
172+ assertFailure := func(t *testing.T, supervisor *Supervisor, agent *Agent, stateDir string) {
173+ t.Helper()
174+ supervisor.captureWorkspace(agent)
175+ supervisor.ensureResult(agent, StatusDone, nil)
176+ checkCaptureFailureResult(t, agent, stateDir)
177+ }
178+
179+ t.Run("stat", func(t *testing.T) {
180+ stateDir := filepath.Join(t.TempDir(), "missing")
181+ agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
182+ t.Fatal("workspace capture should not run when the state directory cannot be stat'ed")
183+ return WorkspaceCapture{}, nil
184+ })
185+ assertFailure(t, &Supervisor{}, agent, stateDir)
186+ })
187+
188+ t.Run("capture", func(t *testing.T) {
189+ stateDir := t.TempDir()
190+ agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
191+ return WorkspaceCapture{}, errors.New("capture leaked /private/secret")
192+ })
193+ assertFailure(t, &Supervisor{}, agent, stateDir)
194+ })
195+
196+ t.Run("temp write", func(t *testing.T) {
197+ stateDir := t.TempDir()
198+ if err := os.Mkdir(patchPath(stateDir)+".tmp", 0o700); err != nil {
199+ t.Fatal(err)
200+ }
201+ agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
202+ return WorkspaceCapture{Patch: []byte("patch")}, nil
203+ })
204+ assertFailure(t, &Supervisor{}, agent, stateDir)
205+ })
206+
207+ t.Run("rename", func(t *testing.T) {
208+ stateDir := t.TempDir()
209+ if err := os.Mkdir(patchPath(stateDir), 0o700); err != nil {
210+ t.Fatal(err)
211+ }
212+ agent := makeAgent(stateDir, func() (WorkspaceCapture, error) {
213+ return WorkspaceCapture{Patch: []byte("patch")}, nil
214+ })
215+ assertFailure(t, &Supervisor{}, agent, stateDir)
216+ if _, err := os.Stat(patchPath(stateDir) + ".tmp"); !os.IsNotExist(err) {
217+ t.Fatalf("workspace temporary patch still exists, stat error = %v", err)
218+ }
219+ })
220+}
221+
222+func TestCaptureWorkspaceSuccessPersistsPatch(t *testing.T) {
223+ stateDir := t.TempDir()
224+ agent := &Agent{
225+ ID: "agent-1",
226+ stateDir: stateDir,
227+ workspaceCapture: func() (WorkspaceCapture, error) {
228+ return WorkspaceCapture{Patch: []byte("patch"), ChangedFiles: []string{"file.txt"}}, nil
229+ },
230+ }
231+ (&Supervisor{}).captureWorkspace(agent)
232+
233+ patch, err := os.ReadFile(patchPath(stateDir))
234+ if err != nil {
235+ t.Fatal(err)
236+ }
237+ if string(patch) != "patch" {
238+ t.Fatalf("patch = %q, want %q", patch, "patch")
239+ }
240+ agent.lifecycleMu.Lock()
241+ patchRef := agent.patchRef
242+ changedFiles := append([]string(nil), agent.changedFiles...)
243+ agent.lifecycleMu.Unlock()
244+ if patchRef != PatchRef(agent.ID) || len(changedFiles) != 1 || changedFiles[0] != "file.txt" {
245+ t.Fatalf("capture metadata = ref %q, changed files %#v", patchRef, changedFiles)
246+ }
247+}
248+
249+func checkCaptureFailureResult(t *testing.T, agent *Agent, stateDir string) {
250+ t.Helper()
251+ result := agent.Result()
252+ if result == nil || result.Status != ResultFailed || result.Error == nil {
253+ t.Fatalf("capture failure result = %#v", result)
254+ }
255+ if result.Error.Code != workspaceCaptureErrorCode || result.Error.Message != workspaceCaptureErrorMessage {
256+ t.Fatalf("capture failure error = %#v", result.Error)
257+ }
258+ if strings.Contains(result.Error.Message, stateDir) || strings.Contains(result.Error.Message, "secret") {
259+ t.Fatalf("capture failure leaked private data: %#v", result.Error)
260+ }
261+ durable, err := readTurnResult(stateDir)
262+ if err != nil {
263+ t.Fatal(err)
264+ }
265+ if durable.Error == nil || durable.Error.Code != workspaceCaptureErrorCode || durable.Status != ResultFailed {
266+ t.Fatalf("durable capture failure = %#v", durable)
267+ }
268+}
269+
270+func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
271+ tests := []struct {
272+ name string
273+ status Status
274+ runErr error
275+ wantState TurnStatus
276+ }{
277+ {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed},
278+ {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled},
279+ {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled},
280+ }
281+ for _, tt := range tests {
282+ t.Run(tt.name, func(t *testing.T) {
283+ stateDir := t.TempDir()
284+ agent := &Agent{
285+ ID: "agent-1",
286+ stateDir: stateDir,
287+ result: &TurnResult{
288+ AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
289+ },
290+ }
291+ supervisor := &Supervisor{}
292+ supervisor.ensureResult(agent, tt.status, tt.runErr)
293+ result := agent.Result()
294+ if result == nil || result.Status != tt.wantState {
295+ t.Fatalf("result status = %#v, want %q", result, tt.wantState)
296+ }
297+ if result.Error == nil {
298+ t.Fatal("runner error was not recorded")
299+ }
300+ durable, err := readTurnResult(stateDir)
301+ if err != nil {
302+ t.Fatal(err)
303+ }
304+ if durable.Status != tt.wantState {
305+ t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState)
306+ }
307+ })
308+ }
309+}
A packages/agent/subagents/runner.go +705 -0
@@ -0,0 +1,705 @@
1+package subagents
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "sync"
15+ "time"
16+)
17+
18+// execRunner spawns `zot --subagent-worker <inbox> --session <path>` in
19+// Agent.Dir (the shared repository root or an isolated worktree) and consumes
20+// its JSONL event stream on stdout.
21+//
22+// Why a long-lived daemon and not `zot --print`: the supervisor and
23+// the user expect agents to keep accepting follow-up prompts. A
24+// one-shot subprocess can't do that; this design gives each subagent
25+// agent a persistent session file plus an inbox socket the parent
26+// writes to, mirroring Claude Code's "Agents view" model.
27+//
28+// Events flow:
29+//
30+// child stdout --> decoder --> EventLog (events.jsonl)
31+// --> Sink (Activity/Transcript)
32+//
33+// The on-disk log is the durable record. The Sink updates are an
34+// in-memory mirror so the dashboard doesn't have to tail the file
35+// for the parent's own agents. /subagents open in a separate zot would
36+// read the log directly.
37+type execRunner struct {
38+ agent *Agent
39+ resolveCredential func(context.Context, string) (Credential, error)
40+
41+ // Command overrides the default `zot --subagent-worker ...`
42+ // invocation. Tests set this to a fake binary (or `go run`
43+ // against a tiny stub program) so the supervisor logic can be
44+ // tested without a real child. Production code leaves it nil.
45+ Command []string
46+
47+ // SessionPath is the agent's session file. Empty means "defer
48+ // to r.agent.SessionPath", which Supervisor.Spawn always populates
49+ // with <subagent-root>/agents/<id>/session.json. Tests that
50+ // hand-build an Agent without going through Spawn must set
51+ // one of the two; the runner refuses to invent a fallback
52+ // because the only plausible one (<Dir>/.zot/session.json)
53+ // would litter the user's repo — every agent's Dir points
54+ // at it directly.
55+ SessionPath string
56+}
57+
58+// subagentWorkerArgsOpts captures every dynamic input to subagentWorkerArgs.
59+// The fields map 1:1 onto child CLI flags; empty values omit the flag
60+// entirely and let the child resolve a default the same way a normal
61+// `zot` invocation does.
62+type subagentWorkerArgsOpts struct {
63+ Exe string
64+ Dir string
65+ SessionPath string
66+ InboxPath string
67+ Task string
68+ Model string
69+ Provider string
70+ BaseURL string
71+ InsecureTLS bool
72+ Reasoning string
73+ FastMode bool
74+ FastModeSet bool
75+ Subagent string
76+ MaxTurns int
77+ Tools []string
78+}
79+
80+// defaultChildArgs builds the argv execRunner uses when its Command
81+// override is empty. Centralised so the spawn-vs-resume decision
82+// (whether to pass the original Task as a positional) lives in one
83+// place that tests can hit directly without going through Run's
84+// side effects.
85+//
86+// On Spawn (Resuming==false) we pass the task so the child's first
87+// turn runs immediately. On Resume (Resuming==true) we omit it: the
88+// child reopens the existing session file, loads the prior
89+// conversation, and just waits on the inbox for the next prompt.
90+// Re-firing the task on every resume produces a duplicate turn that
91+// collides with whatever the user types next, surfacing the
92+// "agent busy; send 'cancel' first" error between two assistant
93+// messages — which is exactly the bug this helper fixes.
94+func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string {
95+ task := a.Task
96+ if a.Resuming {
97+ task = ""
98+ }
99+ return subagentWorkerArgs(subagentWorkerArgsOpts{
100+ Exe: exe,
101+ Dir: a.Dir,
102+ SessionPath: sessionPath,
103+ InboxPath: inboxPath,
104+ Task: task,
105+ Model: a.Model,
106+ Provider: a.Provider,
107+ BaseURL: a.BaseURL,
108+ InsecureTLS: a.InsecureTLS,
109+ Reasoning: a.Reasoning,
110+ FastMode: a.FastMode,
111+ FastModeSet: true,
112+ Subagent: a.Subagent,
113+ MaxTurns: a.MaxTurns,
114+ Tools: a.Tools,
115+ })
116+}
117+
118+// subagentWorkerArgs builds the argv used when execRunner.Command is
119+// empty. Pulled out so tests can lock in the flag set without
120+// actually spawning a subprocess.
121+func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string {
122+ args := []string{
123+ opts.Exe,
124+ "--subagent-worker", opts.InboxPath,
125+ "--session", opts.SessionPath,
126+ "--cwd", opts.Dir,
127+ }
128+ if opts.Model != "" {
129+ args = append(args, "--model", opts.Model)
130+ }
131+ if opts.Provider != "" {
132+ args = append(args, "--provider", opts.Provider)
133+ }
134+ if opts.BaseURL != "" {
135+ args = append(args, "--base-url", opts.BaseURL)
136+ }
137+ if opts.InsecureTLS {
138+ args = append(args, "--insecure")
139+ }
140+ if opts.Reasoning != "" {
141+ args = append(args, "--reasoning", opts.Reasoning)
142+ }
143+ if opts.FastModeSet {
144+ if opts.FastMode {
145+ args = append(args, "--fast-mode")
146+ } else {
147+ args = append(args, "--no-fast-mode")
148+ }
149+ }
150+ if opts.Subagent != "" {
151+ args = append(args, "--subagent", opts.Subagent)
152+ }
153+ if opts.MaxTurns > 0 {
154+ args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns))
155+ }
156+ if len(opts.Tools) > 0 {
157+ args = append(args, "--tools", strings.Join(opts.Tools, ","))
158+ }
159+ if opts.Task != "" {
160+ // First task is positional so the child treats it as the
161+ // initial user turn; the terminator keeps flag-like task text
162+ // from being parsed as a child CLI option.
163+ args = append(args, "--", opts.Task)
164+ }
165+ return args
166+}
167+
168+func (r *execRunner) Run(ctx context.Context, sink Sink) error {
169+ // SessionPath resolution order:
170+ // 1. explicit r.SessionPath set by the test / caller
171+ // 2. r.agent.SessionPath baked in by Supervisor.Spawn — the
172+ // production path. Always lives under
173+ // <subagent-root>/agents/<id>/session.json so the per-
174+ // agent state is entirely outside the working tree.
175+ // Crucial because Agent.Dir points at the user's repo;
176+ // any .zot/ scratch directory under Dir would litter
177+ // their source tree.
178+ //
179+ // There is no third fallback. If neither path is set we
180+ // refuse to start instead of inventing a directory; that
181+ // way a misconfigured caller fails loudly the first time
182+ // instead of silently dumping session data into someone's
183+ // repo.
184+ sessionPath := r.SessionPath
185+ if sessionPath == "" {
186+ sessionPath = r.agent.SessionPath
187+ }
188+ if sessionPath == "" {
189+ return fmt.Errorf("subagent: agent missing session path (set SpawnRequest via Supervisor.SpawnReq, or hand-build Agent with SessionPath populated)")
190+ }
191+ if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
192+ return fmt.Errorf("session dir: %w", err)
193+ }
194+
195+ inboxPath := r.agent.InboxPath
196+ logPath := r.agent.EventLogPath
197+ if logPath == "" {
198+ return fmt.Errorf("subagent: agent missing event log path")
199+ }
200+ log, err := OpenEventLog(logPath)
201+ if err != nil {
202+ return err
203+ }
204+ defer log.Close()
205+
206+ args := r.Command
207+ if len(args) == 0 {
208+ exe, err := os.Executable()
209+ if err != nil {
210+ return fmt.Errorf("locate self: %w", err)
211+ }
212+ args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath)
213+ }
214+
215+ cmd := exec.CommandContext(ctx, args[0], args[1:]...)
216+ cmd.Dir = r.agent.Dir
217+ cmd.Env = append(workerEnvironment(r.agent.Provider),
218+ "ZOT_SUBAGENT_AGENT_ID="+r.agent.ID,
219+ "ZOT_SUBAGENT_EVENT_LOG="+logPath,
220+ )
221+ if r.agent.HeartbeatInterval > 0 {
222+ cmd.Env = append(cmd.Env, "ZOT_SUBAGENT_HEARTBEAT_INTERVAL="+r.agent.HeartbeatInterval.String())
223+ }
224+ if r.agent.maxOutputBytes > 0 {
225+ cmd.Env = append(cmd.Env, fmt.Sprintf("ZOT_SUBAGENT_MAX_OUTPUT_BYTES=%d", r.agent.maxOutputBytes))
226+ }
227+ if r.agent.maxOutputLines > 0 {
228+ cmd.Env = append(cmd.Env, fmt.Sprintf("ZOT_SUBAGENT_MAX_OUTPUT_LINES=%d", r.agent.maxOutputLines))
229+ }
230+ if r.resolveCredential != nil {
231+ credential, resolveErr := r.resolveCredential(ctx, r.agent.Provider)
232+ if resolveErr != nil {
233+ return fmt.Errorf("resolve subagent credential for %s: %w", r.agent.Provider, resolveErr)
234+ }
235+ if credential.Value != "" {
236+ encoded, encodeErr := json.Marshal(credential)
237+ if encodeErr != nil {
238+ return fmt.Errorf("encode subagent credential: %w", encodeErr)
239+ }
240+ cmd.Stdin = bytes.NewReader(encoded)
241+ cmd.Env = append(cmd.Env, "ZOT_SUBAGENT_CREDENTIAL_STDIN=1")
242+ }
243+ }
244+
245+ stdout, err := cmd.StdoutPipe()
246+ if err != nil {
247+ return err
248+ }
249+ stderr, err := cmd.StderrPipe()
250+ if err != nil {
251+ return err
252+ }
253+ configureWorkerProcess(cmd, stdout, stderr)
254+
255+ // "spawning" is briefly shown until the first event arrives;
256+ // the child's "spawned" lifecycle event then overwrites it.
257+ sink.Activity("starting")
258+ if err := cmd.Start(); err != nil {
259+ return err
260+ }
261+ r.agent.setProcessPID(cmd.Process.Pid)
262+ if r.agent.persistFn != nil {
263+ r.agent.persistFn(r.agent)
264+ }
265+
266+ var logErrMu sync.Mutex
267+ var logErr error
268+ var stopOnLogErr sync.Once
269+ appendLog := func(ev Event) {
270+ if err := log.Append(ev); err != nil {
271+ logErrMu.Lock()
272+ if logErr == nil {
273+ logErr = err
274+ }
275+ logErrMu.Unlock()
276+ // A durable event log is part of the runner's recovery contract.
277+ // Stop the worker rather than letting it continue in a state the
278+ // supervisor cannot reconstruct after restart.
279+ stopOnLogErr.Do(func() { _ = killProcessGroup(cmd) })
280+ }
281+ }
282+ firstLogErr := func() error {
283+ logErrMu.Lock()
284+ defer logErrMu.Unlock()
285+ return logErr
286+ }
287+
288+ // stdout: parsed as JSONL. Every well-formed event is appended
289+ // to the durable log AND forwarded to the in-memory sink so the
290+ // dashboard updates without having to tail the file. Malformed
291+ // lines are surfaced as plain transcript so they don't vanish.
292+ done := make(chan struct{}, 2)
293+ go func() {
294+ defer func() { done <- struct{}{} }()
295+ dec := bufio.NewReader(stdout)
296+ lineLimit := r.agent.maxOutputBytes + 64*1024
297+ if lineLimit <= 0 {
298+ lineLimit = 512 * 1024
299+ }
300+ if lineLimit > maxEventLogLineBytes {
301+ lineLimit = maxEventLogLineBytes
302+ }
303+ for {
304+ line, err, truncated := readBoundedLine(dec, lineLimit)
305+ if len(line) > 0 {
306+ trimmed := strings.TrimRight(string(line), "\r\n")
307+ if trimmed != "" && !truncated {
308+ if ev, ok := parseEventLine(trimmed); ok {
309+ appendLog(ev)
310+ updateAgentFromEvent(r.agent, ev)
311+ applyEventToSink(ev, sink)
312+ // Fan prompt-level task completions up to any
313+ // subscriber on the supervised Agent. The child
314+ // also forwards provider/tool-loop turn_end
315+ // events (for example stop=tool_use); those do
316+ // not contain step and must not be treated as
317+ // subagent task completion.
318+ notifyPromptTurnEnd(r.agent, ev)
319+ } else {
320+ sink.Transcript(trimmed)
321+ appendLog(NewEvent("stdout", map[string]any{"text": trimmed}))
322+ }
323+ } else if trimmed != "" {
324+ trimmed += "\n...[line truncated]"
325+ sink.Transcript(trimmed)
326+ appendLog(NewEvent("stdout", map[string]any{"text": trimmed, "truncated": true}))
327+ }
328+ }
329+ if err != nil {
330+ return
331+ }
332+ }
333+ }()
334+
335+ // stderr: lifecycle/error chatter from the child. Every line
336+ // is mirrored as a stderr event in the durable log AND surfaced
337+ // in the transcript so users can diagnose a failing agent
338+ // without leaving the dashboard.
339+ go func() {
340+ defer func() { done <- struct{}{} }()
341+ br := bufio.NewReader(stderr)
342+ lineLimit := r.agent.maxOutputBytes + 64*1024
343+ if lineLimit <= 0 {
344+ lineLimit = 512 * 1024
345+ }
346+ if lineLimit > maxEventLogLineBytes {
347+ lineLimit = maxEventLogLineBytes
348+ }
349+ for {
350+ line, err, truncated := readBoundedLine(br, lineLimit)
351+ if len(line) > 0 {
352+ txt := strings.TrimRight(string(line), "\r\n")
353+ if truncated {
354+ txt += "\n...[line truncated]"
355+ }
356+ sink.Transcript("stderr: " + txt)
357+ appendLog(NewEvent("stderr", map[string]any{"text": txt, "truncated": truncated}))
358+ }
359+ if err != nil {
360+ return
361+ }
362+ }
363+ }()
364+
365+ <-done
366+ <-done
367+
368+ err = cmd.Wait()
369+ exit := 0
370+ if ee, ok := err.(*exec.ExitError); ok {
371+ exit = ee.ExitCode()
372+ }
373+ if err != nil && ctx.Err() != nil {
374+ appendLog(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"}))
375+ if logErr := firstLogErr(); logErr != nil {
376+ return fmt.Errorf("subagent event log: %w", logErr)
377+ }
378+ return ctx.Err()
379+ }
380+ if err != nil {
381+ appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()}))
382+ if logErr := firstLogErr(); logErr != nil {
383+ return fmt.Errorf("subagent event log: %v; worker: %w", logErr, err)
384+ }
385+ return err
386+ }
387+ appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0}))
388+ if logErr := firstLogErr(); logErr != nil {
389+ return fmt.Errorf("subagent event log: %w", logErr)
390+ }
391+ sink.Activity("done")
392+ return nil
393+}
394+
395+func workerEnvironment(provider string) []string {
396+ // Provider credentials are resolved by the supervisor and transferred
397+ // over the child's stdin. Do not inherit API-key/token variables into the
398+ // worker environment, where child tools, crash reports, or diagnostics
399+ // could expose them. Bedrock's current client still resolves bearer/IAM
400+ // credentials directly from AWS environment variables, so preserve that
401+ // provider's ambient AWS chain until it has a structured stdin handoff.
402+ provider = strings.ToLower(strings.TrimSpace(provider))
403+ var env []string
404+ for _, entry := range os.Environ() {
405+ name, _, ok := strings.Cut(entry, "=")
406+ if ok && workerSecretEnvName(name) && !(provider == "amazon-bedrock" && isBedrockCredentialEnv(name)) {
407+ continue
408+ }
409+ env = append(env, entry)
410+ }
411+ return env
412+}
413+
414+func isBedrockCredentialEnv(name string) bool {
415+ switch strings.ToUpper(strings.TrimSpace(name)) {
416+ case "AWS_BEARER_TOKEN_BEDROCK", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN":
417+ return true
418+ default:
419+ return false
420+ }
421+}
422+
423+func workerSecretEnvName(name string) bool {
424+ name = strings.ToUpper(strings.TrimSpace(name))
425+ for _, suffix := range []string{"_KEY", "_API_KEY", "_OAUTH_TOKEN", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_CREDENTIALS"} {
426+ if strings.HasSuffix(name, suffix) {
427+ return true
428+ }
429+ }
430+ switch name {
431+ case "AWS_ACCESS_KEY_ID", "HF_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "COPILOT_GITHUB_TOKEN", "GITHUB_COPILOT_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS":
432+ return true
433+ default:
434+ return false
435+ }
436+}
437+
438+func readBoundedLine(r *bufio.Reader, limit int) ([]byte, error, bool) {
439+ line, err, truncated, _, _ := readBoundedLineStats(r, limit)
440+ return line, err, truncated
441+}
442+
443+// readBoundedLineStats reads through one newline or EOF while retaining the
444+// number of source bytes consumed and whether a newline terminated the record.
445+// The follower uses those extra values to avoid advancing past a partial final
446+// JSONL record that may be completed by a concurrent append.
447+func readBoundedLineStats(r *bufio.Reader, limit int) ([]byte, error, bool, int64, bool) {
448+ if limit <= 0 {
449+ limit = 512 * 1024
450+ }
451+ var line []byte
452+ var consumed int64
453+ truncated := false
454+ for {
455+ chunk, err := r.ReadSlice('\n')
456+ consumed += int64(len(chunk))
457+ if len(chunk) > 0 {
458+ if len(line) < limit {
459+ keep := len(chunk)
460+ if remaining := limit - len(line); keep > remaining {
461+ keep = remaining
462+ truncated = true
463+ }
464+ line = append(line, chunk[:keep]...)
465+ } else {
466+ truncated = true
467+ }
468+ }
469+ if err != bufio.ErrBufferFull {
470+ return line, err, truncated, consumed, len(chunk) > 0 && chunk[len(chunk)-1] == '\n'
471+ }
472+ }
473+}
474+
475+// parseEventLine attempts to decode one current-protocol JSONL line as an Event.
476+func parseEventLine(line string) (Event, bool) {
477+ if len(line) == 0 || line[0] != '{' {
478+ return Event{}, false
479+ }
480+ env, err := ParseEvent([]byte(line))
481+ if err != nil {
482+ return Event{}, false
483+ }
484+ fields, err := env.PayloadFields()
485+ if err != nil {
486+ return Event{}, false
487+ }
488+ data := make(map[string]any, len(fields))
489+ for key, raw := range fields {
490+ var value any
491+ if err := json.Unmarshal(raw, &value); err != nil {
492+ return Event{}, false
493+ }
494+ data[key] = value
495+ }
496+ when := env.Timestamp
497+ if when.IsZero() {
498+ when = time.Now()
499+ }
500+ return Event{
501+ Time: when, Type: env.Type, Version: env.Version,
502+ MessageID: env.MessageID, AgentID: env.AgentID, TurnID: env.TurnID,
503+ Data: data,
504+ }, true
505+}
506+
507+func updateAgentFromEvent(a *Agent, ev Event) {
508+ if a == nil {
509+ return
510+ }
511+ now := ev.Time
512+ if now.IsZero() {
513+ now = time.Now()
514+ }
515+ a.markActivity(now)
516+ persist := false
517+ switch ev.Type {
518+ case EventAgentReady, "agent_ready":
519+ a.setProcessState(ProcessAlive)
520+ a.setTurnState(TurnIdle, ev.TurnID)
521+ persist = true
522+ case EventAgentHeartbeat, "agent_heartbeat":
523+ a.setProcessState(ProcessAlive)
524+ persist = true
525+ case EventTurnStarted, "turn_start":
526+ turnID := ev.TurnID
527+ if turnID == "" {
528+ if step, ok := ev.Data["step"].(float64); ok {
529+ turnID = fmt.Sprintf("turn-%d", int(step))
530+ }
531+ }
532+ a.setProcessState(ProcessAlive)
533+ a.setTurnState(TurnRunning, turnID)
534+ persist = true
535+ case EventTurnResult, "turn_result":
536+ if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {
537+ a.setResult(result)
538+ if a.turnResults != nil {
539+ select {
540+ case a.turnResults <- result:
541+ default:
542+ }
543+ }
544+ if a.stateDir != "" {
545+ if err := writeTurnResult(a.stateDir, result); err == nil {
546+ a.lifecycleMu.Lock()
547+ a.resultRef = ResultRef(a.ID)
548+ a.lifecycleMu.Unlock()
549+ }
550+ }
551+ switch result.Status {
552+ case ResultCanceled:
553+ a.setTurnState(TurnCanceled, result.TurnID)
554+ case ResultFailed:
555+ a.setTurnState(TurnFailed, result.TurnID)
556+ default:
557+ a.setTurnState(TurnSucceeded, result.TurnID)
558+ }
559+ }
560+ persist = true
561+ case EventTurnFailed, "turn_failed":
562+ a.setTurnState(TurnFailed, ev.TurnID)
563+ persist = true
564+ case EventAgentIdle, "agent_idle":
565+ a.setTurnState(TurnIdle, ev.TurnID)
566+ persist = true
567+ case EventAgentExited, "agent_exited", "agent_stopped":
568+ a.setProcessState(ProcessExited)
569+ persist = true
570+ case "turn_end":
571+ // Provider/tool-loop turn_end events (for example stop=tool_use)
572+ // do not carry the daemon's prompt step and are not terminal for
573+ // the delegated task. They remain in the event log, but must not
574+ // overwrite the delegated turn state or trigger persistence.
575+ if _, ok := ev.Data["step"].(float64); !ok {
576+ break
577+ }
578+ if message, _ := ev.Data["error"].(string); message != "" {
579+ a.setTurnState(TurnFailed, ev.TurnID)
580+ } else {
581+ a.setTurnState(TurnSucceeded, ev.TurnID)
582+ }
583+ persist = true
584+ }
585+ if persist && a.persistFn != nil {
586+ a.persistFn(a)
587+ }
588+}
589+
590+// notifyPromptTurnEnd calls Agent.OnTurnEnd only for the subagent
591+// daemon's prompt-level completion event. Provider/tool-loop
592+// turn_end events (such as stop=tool_use) do not include step and
593+// are not terminal for the delegated task.
594+func notifyPromptTurnEnd(a *Agent, ev Event) {
595+ if a == nil || ev.Type != "turn_end" {
596+ return
597+ }
598+ step, ok := ev.Data["step"].(float64)
599+ if !ok {
600+ return
601+ }
602+
603+ errMsg, _ := ev.Data["error"].(string)
604+ a.mu.Lock()
605+ fn := a.OnTurnEnd
606+ if fn == nil {
607+ a.pendingTurnEnds = append(a.pendingTurnEnds, turnEndNotice{step: int(step), errMsg: errMsg})
608+ a.mu.Unlock()
609+ return
610+ }
611+ a.mu.Unlock()
612+ go fn(int(step), errMsg)
613+}
614+
615+// applyEventToSink translates an Event into Sink updates. Only a
616+// few event types are interpreted; the rest still land in the
617+// durable log via the caller.
618+func applyEventToSink(ev Event, sink Sink) {
619+ type roleSink interface {
620+ userMessage(string)
621+ assistantMessage(string)
622+ }
623+ appendMessage := func(text string, assistant bool) {
624+ if text == "" {
625+ return
626+ }
627+ if roles, ok := sink.(roleSink); ok {
628+ if assistant {
629+ roles.assistantMessage(text)
630+ } else {
631+ roles.userMessage(text)
632+ }
633+ return
634+ }
635+ if assistant {
636+ sink.Transcript(text)
637+ } else {
638+ sink.Transcript("user: " + text)
639+ }
640+ }
641+
642+ switch ev.Type {
643+ case "assistant_message", "user_message":
644+ var text []string
645+ if c, ok := ev.Data["content"].([]any); ok {
646+ for _, blk := range c {
647+ m, _ := blk.(map[string]any)
648+ if t, _ := m["type"].(string); t == "text" {
649+ if txt, _ := m["text"].(string); txt != "" {
650+ text = append(text, txt)
651+ }
652+ }
653+ }
654+ }
655+ appendMessage(strings.Join(text, "\n"), ev.Type == "assistant_message")
656+ if ev.Type == "assistant_message" {
657+ sink.Activity("idle")
658+ }
659+ case "turn_start", EventTurnStarted:
660+ sink.Activity("thinking")
661+ case "tool_call", EventToolStarted:
662+ if name, _ := ev.Data["name"].(string); name != "" {
663+ sink.Activity("tool: " + truncate(name, 60))
664+ }
665+ case "tool_result", EventToolFinished:
666+ sink.Activity("idle")
667+ case "turn_end", EventTurnResult, EventAgentIdle:
668+ sink.Activity("idle")
669+ case "agent_ready", EventAgentReady, EventAgentHeartbeat:
670+ sink.Activity("idle")
671+ case "agent_stopped":
672+ // terminal status is decided by Supervisor.run from the runner's
673+ // return value, not from this event. Don't overwrite the
674+ // activity here.
675+ case "error":
676+ if msg, _ := ev.Data["message"].(string); msg != "" {
677+ sink.Transcript("error: " + msg)
678+ }
679+ }
680+}
681+
682+// RunnerFunc adapts a plain function into a Runner. Useful for tests
683+// and for callers who don't need their own type.
684+type RunnerFunc func(ctx context.Context, sink Sink) error
685+
686+func (f RunnerFunc) Run(ctx context.Context, sink Sink) error { return f(ctx, sink) }
687+
688+// streamLines kept around for any caller still using it directly.
689+//
690+// Deprecated: the runner now parses JSONL from stdout via
691+// parseEventLine; this helper is unused inside the package but
692+// remains exported via internal use by tests in the runner_test
693+// suite that pre-date the daemon switch.
694+func streamLines(r io.Reader, fn func(string)) {
695+ br := bufio.NewReader(r)
696+ for {
697+ line, err := br.ReadString('\n')
698+ if line != "" {
699+ fn(strings.TrimRight(line, "\r\n"))
700+ }
701+ if err != nil {
702+ return
703+ }
704+ }
705+}
R packages/agent/swarm/runner_e2e_test.go → packages/agent/subagents/runner_e2e_test.go +10 -9 renamed, 88% similar
@@ -1,4 +1,4 @@
1-package swarm
1+package subagents
22
33 import (
44 "context"
@@ -15,11 +15,11 @@ import (
1515 // TestRunnerEndToEndWithStubChild is the integration test for the
1616 // new daemon-mode runner. It compiles the stubchild binary in
1717 // testdata/cmd/stubchild, points an execRunner at it, and drives
18-// a Swarm through one Spawn + one SendUserTurn + Stop cycle.
18+// a Supervisor through one Spawn + one SendUserTurn + Stop cycle.
1919 //
2020 // What this proves:
2121 //
22-// - The default argv shape (swarmAgentArgs) is one the child can
22+// - The default argv shape (subagentWorkerArgs) is one the child can
2323 // actually parse — locks the shape against silent breakage.
2424 // - The stdout JSONL parser ingests events and writes them to the
2525 // durable log.
@@ -40,6 +40,7 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) {
4040 }
4141
4242 exe := buildStubChild(t)
43+ t.Setenv("ZOT_STUB_PROTOCOL", "1")
4344
4445 root := t.TempDir()
4546 repo := t.TempDir()
@@ -49,7 +50,7 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) {
4950 NewRunner: func(a *Agent) Runner {
5051 return &execRunner{
5152 agent: a,
52- Command: swarmAgentArgs(swarmAgentArgsOpts{
53+ Command: subagentWorkerArgs(subagentWorkerArgsOpts{
5354 Exe: exe,
5455 Dir: a.Dir,
5556 SessionPath: a.SessionPath,
@@ -93,13 +94,13 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) {
9394
9495 // Send a follow-up over the inbox. The stub echoes the text into
9596 // an assistant_message we can poll for in the log.
96- if err := retrySend(f, a.ID, "user follow up", time.Second); err != nil {
97- t.Fatalf("SendInput: %v", err)
97+ if err := retrySend(f, a.ID, "follow up", time.Second); err != nil {
98+ t.Fatalf("SendUserTurn: %v", err)
9899 }
99100 waitFor("echo: follow up")
100101
101102 // Shut the agent down gracefully via the inbox.
102- if err := f.SendInput(a.ID, "shutdown"); err != nil && !errors.Is(err, ErrNotReady) {
103+ if err := a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil && !errors.Is(err, ErrNotReady) {
103104 t.Fatalf("shutdown send: %v", err)
104105 }
105106 a.Wait()
@@ -111,11 +112,11 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) {
111112 // retrySend exists because the inbox dial races against the child
112113 // opening the socket. Production callers handle ErrNotReady with a
113114 // status message; tests retry within a small window.
114-func retrySend(f *Swarm, id, msg string, timeout time.Duration) error {
115+func retrySend(f *Supervisor, id, msg string, timeout time.Duration) error {
115116 deadline := time.Now().Add(timeout)
116117 var lastErr error
117118 for time.Now().Before(deadline) {
118- err := f.SendInput(id, msg)
119+ err := f.SendUserTurn(id, msg)
119120 if err == nil {
120121 return nil
121122 }
R packages/agent/swarm/runner_test.go → packages/agent/subagents/runner_test.go +151 -16 renamed, 58% similar
@@ -1,4 +1,4 @@
1-package swarm
1+package subagents
22
33 import (
44 "context"
@@ -11,8 +11,8 @@ import (
1111 "time"
1212 )
1313
14-// TestSwarmAgentArgs locks in the exact flag set the subprocess
15-// runner uses to start a swarm agent in daemon mode. Past
14+// TestSubagentWorkerArgs locks in the exact flag set the subprocess
15+// runner uses to start a subagent agent in daemon mode. Past
1616 // regressions in this area:
1717 //
1818 // - "--no-sess" instead of "--no-session" (old print-mode runner):
@@ -31,10 +31,10 @@ import (
3131 // positions. If a flag is renamed, update both the runner and this
3232 // test so we notice immediately.
3333 func TestCredentialStdinHelperProcess(t *testing.T) {
34- if os.Getenv("ZOT_SWARM_CREDENTIAL_HELPER") != "1" {
34+ if os.Getenv("ZOT_SUBAGENT_CREDENTIAL_HELPER") != "1" {
3535 return
3636 }
37- if os.Getenv("ZOT_SWARM_CREDENTIAL_STDIN") != "1" {
37+ if os.Getenv("ZOT_SUBAGENT_CREDENTIAL_STDIN") != "1" {
3838 os.Exit(2)
3939 }
4040 var credential Credential
@@ -58,8 +58,47 @@ func TestCredentialStdinHelperProcess(t *testing.T) {
5858 os.Exit(0)
5959 }
6060
61+func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
62+ t.Setenv("OPENAI_API_KEY", "openai-secret")
63+ t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret")
64+ t.Setenv("HF_TOKEN", "hf-secret")
65+ t.Setenv("GITHUB_TOKEN", "github-secret")
66+ t.Setenv("CUSTOM_PROVIDER_TOKEN", "token-secret")
67+ t.Setenv("CUSTOM_PROVIDER_KEY", "key-secret")
68+ t.Setenv("AWS_ACCESS_KEY_ID", "aws-access-secret")
69+ t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/service-account.json")
70+ t.Setenv("ZOT_SUBAGENT_TEST_VALUE", "not-secret")
71+ env := strings.Join(workerEnvironment("openai"), "\n")
72+ for _, secret := range []string{
73+ "OPENAI_API_KEY=openai-secret",
74+ "CUSTOM_PROVIDER_API_KEY=custom-secret",
75+ "HF_TOKEN=hf-secret",
76+ "GITHUB_TOKEN=github-secret",
77+ "CUSTOM_PROVIDER_TOKEN=token-secret",
78+ "CUSTOM_PROVIDER_KEY=key-secret",
79+ "AWS_ACCESS_KEY_ID=aws-access-secret",
80+ "GOOGLE_APPLICATION_CREDENTIALS=/tmp/service-account.json",
81+ } {
82+ if strings.Contains(env, secret) {
83+ t.Fatalf("worker environment leaked %s", secret)
84+ }
85+ }
86+ if !strings.Contains(env, "ZOT_SUBAGENT_TEST_VALUE=not-secret") {
87+ t.Fatal("worker environment dropped unrelated variables")
88+ }
89+
90+ t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-secret")
91+ bedrockEnv := strings.Join(workerEnvironment("amazon-bedrock"), "\n")
92+ if !strings.Contains(bedrockEnv, "AWS_BEARER_TOKEN_BEDROCK=bedrock-secret") {
93+ t.Fatal("Bedrock ambient credential chain was unexpectedly removed")
94+ }
95+ if !strings.Contains(bedrockEnv, "AWS_ACCESS_KEY_ID=aws-access-secret") {
96+ t.Fatal("Bedrock AWS access-key credential was unexpectedly removed")
97+ }
98+}
99+
61100 func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {
62- t.Setenv("ZOT_SWARM_CREDENTIAL_HELPER", "1")
101+ t.Setenv("ZOT_SUBAGENT_CREDENTIAL_HELPER", "1")
63102 root := t.TempDir()
64103 a := &Agent{
65104 ID: "credential-test",
@@ -106,8 +145,8 @@ func TestApplyEventPreservesMultilineRolesAndFinalAssistant(t *testing.T) {
106145 }
107146 }
108147
109-func TestSwarmAgentArgs(t *testing.T) {
110- args := swarmAgentArgs(swarmAgentArgsOpts{
148+func TestSubagentWorkerArgs(t *testing.T) {
149+ args := subagentWorkerArgs(subagentWorkerArgsOpts{
111150 Exe: "/path/to/zot",
112151 Dir: "/tmp/worktree",
113152 SessionPath: "/tmp/state/session.json",
@@ -129,11 +168,11 @@ func TestSwarmAgentArgs(t *testing.T) {
129168 }
130169
131170 mustHave := map[string]string{
132- "--swarm-agent": "/tmp/state/in.sock",
133- "--session": "/tmp/state/session.json",
134- "--cwd": "/tmp/worktree",
135- "--reasoning": "high",
136- "--subagent": "reviewer",
171+ "--subagent-worker": "/tmp/state/in.sock",
172+ "--session": "/tmp/state/session.json",
173+ "--cwd": "/tmp/worktree",
174+ "--reasoning": "high",
175+ "--subagent": "reviewer",
137176 }
138177 for flag, value := range mustHave {
139178 i := indexOf(args, flag)
@@ -155,11 +194,48 @@ func TestSwarmAgentArgs(t *testing.T) {
155194 }
156195 }
157196
158-// TestSwarmAgentArgsEmptyTaskOmitsPositional makes sure that when the
197+func TestSubagentWorkerArgsPropagatesProviderConnectionSettings(t *testing.T) {
198+ args := subagentWorkerArgs(subagentWorkerArgsOpts{
199+ Exe: "/zot",
200+ Dir: "/work",
201+ SessionPath: "/state/session.json",
202+ InboxPath: "/state/inbox.sock",
203+ BaseURL: "https://gateway.example.test/v1",
204+ InsecureTLS: true,
205+ })
206+ if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != "https://gateway.example.test/v1" {
207+ t.Fatalf("argv = %v, want --base-url with inherited endpoint", args)
208+ }
209+ if !containsArg(args, "--insecure") {
210+ t.Fatalf("argv = %v, want inherited --insecure", args)
211+ }
212+ for _, arg := range args {
213+ if strings.Contains(arg, "api-key") || strings.Contains(arg, "secret") {
214+ t.Fatalf("argv contains credential material: %v", args)
215+ }
216+ }
217+}
218+
219+func TestDefaultChildArgsPropagatesProviderConnectionSettings(t *testing.T) {
220+ a := &Agent{
221+ Dir: "/work",
222+ BaseURL: "https://gateway.example.test/v1",
223+ InsecureTLS: true,
224+ }
225+ args := defaultChildArgs("/zot", a, "/state/session.json", "/state/inbox.sock")
226+ if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != a.BaseURL {
227+ t.Fatalf("argv = %v, want Agent.BaseURL propagated", args)
228+ }
229+ if !containsArg(args, "--insecure") {
230+ t.Fatalf("argv = %v, want Agent.InsecureTLS propagated", args)
231+ }
232+}
233+
234+// TestSubagentWorkerArgsEmptyTaskOmitsPositional makes sure that when the
159235 // agent is being adopted (no fresh task) we don't pass an empty
160236 // positional which the arg parser would treat as a real prompt.
161-func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) {
162- args := swarmAgentArgs(swarmAgentArgsOpts{
237+func TestSubagentWorkerArgsEmptyTaskOmitsPositional(t *testing.T) {
238+ args := subagentWorkerArgs(subagentWorkerArgsOpts{
163239 Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",
164240 })
165241 for _, a := range args {
@@ -212,6 +288,40 @@ func TestDefaultChildArgsResumeOmitsTask(t *testing.T) {
212288 }
213289 }
214290
291+func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
292+ a := &Agent{}
293+ a.setTurnState(TurnRunning, "turn-1")
294+ persisted := 0
295+ a.persistFn = func(*Agent) { persisted++ }
296+
297+ updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
298+
299+ if got := a.TurnState(); got != TurnRunning {
300+ t.Fatalf("turn state = %q, want %q", got, TurnRunning)
301+ }
302+ if got := a.CurrentTurnID(); got != "turn-1" {
303+ t.Fatalf("turn ID = %q, want turn-1", got)
304+ }
305+ if persisted != 0 {
306+ t.Fatalf("persist called %d times for provider/tool-loop turn_end, want 0", persisted)
307+ }
308+}
309+
310+func TestUpdateAgentFromEventPersistsTerminalTurnEndWithStep(t *testing.T) {
311+ a := &Agent{}
312+ persisted := 0
313+ a.persistFn = func(*Agent) { persisted++ }
314+
315+ updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
316+
317+ if got := a.TurnState(); got != TurnFailed {
318+ t.Fatalf("turn state = %q, want %q", got, TurnFailed)
319+ }
320+ if persisted != 1 {
321+ t.Fatalf("persist called %d times for terminal turn_end, want 1", persisted)
322+ }
323+}
324+
215325 func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) {
216326 called := make(chan struct{}, 1)
217327 a := &Agent{}
@@ -251,6 +361,31 @@ func TestNotifyPromptTurnEndFiresForDaemonPromptCompletion(t *testing.T) {
251361 }
252362 }
253363
364+func TestSetOnTurnEndReplaysPendingNoticesInArrivalOrder(t *testing.T) {
365+ a := &Agent{}
366+ notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(1)}))
367+ notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
368+
369+ type notice struct {
370+ step int
371+ err string
372+ }
373+ var got []notice
374+ a.SetOnTurnEnd(func(step int, errMsg string) {
375+ got = append(got, notice{step: step, err: errMsg})
376+ })
377+
378+ want := []notice{{step: 1}, {step: 2, err: "boom"}}
379+ if len(got) != len(want) {
380+ t.Fatalf("replayed %d notices, want %d: %+v", len(got), len(want), got)
381+ }
382+ for i := range want {
383+ if got[i] != want[i] {
384+ t.Errorf("replayed notice %d = %+v, want %+v", i, got[i], want[i])
385+ }
386+ }
387+}
388+
254389 func indexOf(xs []string, want string) int {
255390 for i, x := range xs {
256391 if x == want {
A packages/agent/subagents/scheduler.go +374 -0
@@ -0,0 +1,374 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "sync/atomic"
10+ "time"
11+)
12+
13+func (f *Supervisor) validateSpawnRequest(req SpawnRequest) error {
14+ if err := f.validateSpawnOptions(req); err != nil {
15+ return err
16+ }
17+ mode := req.WorkspaceMode
18+ if mode == "" {
19+ mode = WorkspaceShared
20+ }
21+ if mode != WorkspaceShared && mode != WorkspaceWorktree {
22+ return fmt.Errorf("subagents: unknown workspace mode %q", mode)
23+ }
24+ if req.WorkspaceCapture != "" && req.WorkspaceCapture != CapturePatch && req.WorkspaceCapture != CaptureDiff {
25+ return fmt.Errorf("subagents: unknown workspace capture mode %q", req.WorkspaceCapture)
26+ }
27+
28+ f.mu.Lock()
29+ root := f.cfg.RepoRoot
30+ allowedRoots := append([]string(nil), f.cfg.Policy.AllowedRoots...)
31+ f.mu.Unlock()
32+ return validateWorkspaceRoot(root, mode, allowedRoots)
33+}
34+
35+func (f *Supervisor) validateSpawnOptions(req SpawnRequest) error {
36+ p := f.cfg.Policy
37+ if req.MaxTurns < 0 || req.MaxTurns > p.MaxTurns {
38+ return fmt.Errorf("subagents: max_turns must be 0 through %d (zero uses the policy default)", p.MaxTurns)
39+ }
40+ if req.Timeout < 0 {
41+ return errorsInvalid("timeout must not be negative")
42+ }
43+ for _, tool := range req.Tools {
44+ name := strings.TrimSpace(tool)
45+ if name == "" {
46+ continue
47+ }
48+ if !p.allowedTool(name) {
49+ return fmt.Errorf("subagents: tool %q is not allowed by policy", name)
50+ }
51+ }
52+ return nil
53+}
54+
55+func validateWorkspaceRoot(root string, mode WorkspaceMode, allowedRoots []string) error {
56+ if root == "" {
57+ return errorsInvalid("repository root is empty")
58+ }
59+ if len(allowedRoots) > 0 {
60+ ok := false
61+ for _, allowed := range allowedRoots {
62+ if pathWithin(root, allowed) {
63+ ok = true
64+ break
65+ }
66+ }
67+ if !ok {
68+ return fmt.Errorf("subagents: repository root is outside allowed roots")
69+ }
70+ }
71+ if mode == WorkspaceWorktree {
72+ if _, err := os.Stat(filepath.Join(root, ".git")); err != nil {
73+ // Worktrees can be attached to repositories with a .git file or
74+ // a parent checkout. Let the workspace adapter provide the
75+ // detailed Git error, but fail obvious non-checkouts here.
76+ if _, statErr := os.Stat(filepath.Join(root, "HEAD")); statErr != nil {
77+ return fmt.Errorf("subagents: worktree mode requires a Git checkout: %w", err)
78+ }
79+ }
80+ }
81+ return nil
82+}
83+
84+func errorsInvalid(message string) error { return fmt.Errorf("subagents: %s", message) }
85+
86+func pathWithin(path, root string) bool {
87+ pathAbs, err := containmentPath(path)
88+ if err != nil {
89+ return false
90+ }
91+ rootAbs, err := containmentPath(root)
92+ if err != nil {
93+ return false
94+ }
95+ pathAbs = filepath.Clean(pathAbs)
96+ rootAbs = filepath.Clean(rootAbs)
97+ rel, err := filepath.Rel(rootAbs, pathAbs)
98+ if err != nil {
99+ return false
100+ }
101+ return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
102+}
103+
104+// containmentPath returns a canonical path even when the final path does not
105+// exist yet. EvalSymlinks cannot resolve a missing event or session file, so
106+// resolve the nearest existing ancestor and append the missing components.
107+// Without this, a symlinked temporary directory can make a valid child path
108+// look outside its state directory on macOS and Windows.
109+func containmentPath(path string) (string, error) {
110+ absolute, err := filepath.Abs(path)
111+ if err != nil {
112+ return "", err
113+ }
114+ absolute = filepath.Clean(absolute)
115+
116+ current := absolute
117+ var missing []string
118+ for {
119+ if _, statErr := os.Lstat(current); statErr == nil {
120+ evaluated, evalErr := filepath.EvalSymlinks(current)
121+ if evalErr != nil {
122+ return "", evalErr
123+ }
124+ for i := len(missing) - 1; i >= 0; i-- {
125+ evaluated = filepath.Join(evaluated, missing[i])
126+ }
127+ return filepath.Clean(evaluated), nil
128+ } else if !os.IsNotExist(statErr) {
129+ return "", statErr
130+ }
131+
132+ parent := filepath.Dir(current)
133+ if parent == current {
134+ return absolute, nil
135+ }
136+ missing = append(missing, filepath.Base(current))
137+ current = parent
138+ }
139+}
140+
141+func (f *Supervisor) schedule() {
142+ var starts []*Agent
143+ f.mu.Lock()
144+ for len(f.queue) > 0 && f.active < f.cfg.Policy.MaxConcurrent {
145+ idx := f.nextRunnableIndexLocked()
146+ if idx < 0 {
147+ break
148+ }
149+ a := f.queue[idx]
150+ f.queue = append(f.queue[:idx], f.queue[idx+1:]...)
151+
152+ a.mu.Lock()
153+ if a.status != StatusPending {
154+ a.mu.Unlock()
155+ continue
156+ }
157+ a.status = StatusRunning
158+ a.activity = "starting"
159+ a.mu.Unlock()
160+ a.setProcessState(ProcessStarting)
161+ a.setTurnState(TurnIdle, "")
162+ a.incrementAttempt()
163+
164+ f.active++
165+ key := parentKey(a)
166+ f.activeByParent[key]++
167+ if a.BatchID != "" {
168+ f.activeByBatch[a.BatchID]++
169+ }
170+ starts = append(starts, a)
171+ }
172+ f.mu.Unlock()
173+
174+ for _, a := range starts {
175+ f.persistAgent(a)
176+ f.startLifecycleMonitor(a)
177+ go f.run(a)
178+ }
179+}
180+
181+func (f *Supervisor) startLifecycleMonitor(a *Agent) {
182+ if a == nil || f.cfg.Policy.IdleTimeout <= 0 {
183+ return
184+ }
185+ interval := f.cfg.Policy.HeartbeatInterval / 2
186+ if interval <= 0 || interval > time.Minute {
187+ interval = time.Minute
188+ }
189+ go func() {
190+ ticker := time.NewTicker(interval)
191+ defer ticker.Stop()
192+ heartbeatGrace := f.cfg.Policy.HeartbeatInterval + f.cfg.Policy.ReconnectTimeout
193+ for {
194+ select {
195+ case <-a.done:
196+ return
197+ case <-ticker.C:
198+ if a.ProcessState() != ProcessAlive {
199+ continue
200+ }
201+ last := a.LastActivity()
202+ if !last.IsZero() && heartbeatGrace > 0 && time.Since(last) >= heartbeatGrace {
203+ if err := f.Ping(a.ID); err != nil {
204+ a.setProcessState(ProcessDetached)
205+ a.setActivity("detached")
206+ f.persistAgent(a)
207+ continue
208+ }
209+ }
210+ if a.TurnState() == TurnIdle && !last.IsZero() && time.Since(last) >= f.cfg.Policy.IdleTimeout {
211+ _ = f.Stop(a.ID)
212+ return
213+ }
214+ }
215+ }
216+ }()
217+}
218+
219+// Ping asks a live worker for a heartbeat. A detached or terminal worker
220+// returns the same not-ready error as other inbox operations.
221+func (f *Supervisor) Ping(id string) error {
222+ a := f.Get(id)
223+ if a == nil {
224+ return fmt.Errorf("subagents: no such agent %q", id)
225+ }
226+ if a.inbox == nil {
227+ return fmt.Errorf("subagents: agent %s has no inbox", a.ID)
228+ }
229+ return a.inbox.SendCommand(NewCommand(CommandAgentPing, a.ID, a.CurrentTurnID(), AgentPingPayload{}))
230+}
231+
232+func (f *Supervisor) nextRunnableIndexLocked() int {
233+ if f.active >= f.cfg.Policy.MaxConcurrent {
234+ return -1
235+ }
236+ for i, a := range f.queue {
237+ if a == nil {
238+ continue
239+ }
240+ a.mu.Lock()
241+ status := a.status
242+ a.mu.Unlock()
243+ if status != StatusPending {
244+ continue
245+ }
246+ if f.cfg.Policy.MaxConcurrentPerParent > 0 && f.activeByParent[parentKey(a)] >= f.cfg.Policy.MaxConcurrentPerParent {
247+ continue
248+ }
249+ if a.BatchID != "" {
250+ if batch := f.batches[a.BatchID]; batch != nil && batch.MaxConcurrent > 0 && f.activeByBatch[a.BatchID] >= batch.MaxConcurrent {
251+ continue
252+ }
253+ }
254+ return i
255+ }
256+ return -1
257+}
258+
259+func parentKey(a *Agent) string {
260+ if a == nil || strings.TrimSpace(a.ParentID) == "" {
261+ return "supervisor"
262+ }
263+ return a.ParentID
264+}
265+
266+func (f *Supervisor) armQueueTimeout(a *Agent) {
267+ if a == nil {
268+ return
269+ }
270+ if d := f.cfg.Policy.QueueTimeout; d > 0 {
271+ time.AfterFunc(d, func() { f.expireQueued(a.ID) })
272+ }
273+ go func() {
274+ <-a.ctx.Done()
275+ f.cancelQueued(a.ID, a.ctx.Err())
276+ }()
277+}
278+
279+func (f *Supervisor) expireQueued(id string) { f.cancelQueued(id, context.DeadlineExceeded) }
280+
281+func (f *Supervisor) removeQueued(a *Agent) {
282+ if a == nil {
283+ return
284+ }
285+ f.mu.Lock()
286+ for i, queued := range f.queue {
287+ if queued == a {
288+ f.queue = append(f.queue[:i], f.queue[i+1:]...)
289+ break
290+ }
291+ }
292+ f.mu.Unlock()
293+}
294+
295+func (f *Supervisor) cancelQueued(id string, cause error) {
296+ a := f.Get(id)
297+ if a == nil {
298+ return
299+ }
300+
301+ // schedule and Stop use f.mu as the admission lock. Make queued
302+ // cancellation take the same lock while it changes status and removes the
303+ // entry, so a timeout/cancel cannot finalize an agent concurrently with
304+ // scheduler admission.
305+ f.mu.Lock()
306+ a.mu.Lock()
307+ if a.status != StatusPending {
308+ a.mu.Unlock()
309+ f.mu.Unlock()
310+ return
311+ }
312+ a.status = StatusFailed
313+ atomic.CompareAndSwapInt32(&a.launchState, 0, 2)
314+ if cause == context.Canceled {
315+ a.activity = "cancelled while queued"
316+ } else {
317+ a.activity = "queue timeout"
318+ }
319+ a.lastErr = cause
320+ a.finished = f.cfg.Now()
321+ for i, queued := range f.queue {
322+ if queued == a {
323+ f.queue = append(f.queue[:i], f.queue[i+1:]...)
324+ break
325+ }
326+ }
327+ a.mu.Unlock()
328+ f.mu.Unlock()
329+ a.setProcessState(ProcessExited)
330+ if cause == context.Canceled {
331+ a.setTurnState(TurnCanceled, "")
332+ } else {
333+ a.setTurnState(TurnFailed, "")
334+ }
335+ f.captureWorkspace(a)
336+ f.ensureResult(a, StatusFailed, cause)
337+ if a.cancel != nil {
338+ a.cancel()
339+ }
340+ f.persistAgent(a)
341+ if a.workspaceCleanup != nil {
342+ _ = a.workspaceCleanup()
343+ }
344+ _ = a.releaseLease()
345+ a.closeDone()
346+ f.schedule()
347+}
348+
349+func (f *Supervisor) releaseCapacity(a *Agent) {
350+ f.mu.Lock()
351+ if f.active > 0 {
352+ f.active--
353+ }
354+ key := parentKey(a)
355+ if f.activeByParent[key] > 0 {
356+ f.activeByParent[key]--
357+ }
358+ if a != nil && a.BatchID != "" && f.activeByBatch[a.BatchID] > 0 {
359+ f.activeByBatch[a.BatchID]--
360+ }
361+ f.mu.Unlock()
362+ f.schedule()
363+}
364+
365+func (f *Supervisor) persistAgent(a *Agent) {
366+ if a == nil {
367+ return
368+ }
369+ stateDir := a.stateDirectory(f.cfg.Root)
370+ if _, err := os.Stat(stateDir); err != nil {
371+ return
372+ }
373+ _ = writeAgentMeta(stateDir, a)
374+}
A packages/agent/subagents/scheduler_test.go +263 -0
@@ -0,0 +1,263 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+)
12+
13+func TestPathWithinResolvesMissingDescendantThroughSymlink(t *testing.T) {
14+ realRoot := t.TempDir()
15+ aliasParent := filepath.Join(t.TempDir(), "alias")
16+ if err := os.Symlink(realRoot, aliasParent); err != nil {
17+ t.Skipf("directory symlinks unavailable: %v", err)
18+ }
19+ stateDir := filepath.Join(aliasParent, "state")
20+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
21+ t.Fatal(err)
22+ }
23+ missingFile := filepath.Join(stateDir, "events.jsonl")
24+ if !pathWithin(missingFile, stateDir) {
25+ t.Fatalf("pathWithin(%q, %q) = false; want true", missingFile, stateDir)
26+ }
27+}
28+
29+func TestSchedulerBoundsConcurrencyAndReleasesCapacity(t *testing.T) {
30+ root := t.TempDir()
31+ started := make(chan string, 3)
32+ var active, maxActive int32
33+ f := New(Config{
34+ Root: root, RepoRoot: root,
35+ Policy: SubagentPolicy{MaxConcurrent: 1, MaxConcurrentPerParent: 4, MaxTotalSpawned: 3, QueueTimeout: time.Minute, DefaultTimeout: 0, IdleTimeout: time.Hour},
36+ NewRunner: func(a *Agent) Runner {
37+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
38+ current := atomic.AddInt32(&active, 1)
39+ for {
40+ old := atomic.LoadInt32(&maxActive)
41+ if current <= old || atomic.CompareAndSwapInt32(&maxActive, old, current) {
42+ break
43+ }
44+ }
45+ started <- a.ID
46+ <-ctx.Done()
47+ atomic.AddInt32(&active, -1)
48+ return ctx.Err()
49+ })
50+ },
51+ })
52+
53+ first, err := f.Spawn(context.Background(), "first")
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ second, err := f.Spawn(context.Background(), "second")
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ third, err := f.Spawn(context.Background(), "third")
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ select {
66+ case got := <-started:
67+ if got != first.ID {
68+ t.Fatalf("first started = %q, want %q", got, first.ID)
69+ }
70+ case <-time.After(time.Second):
71+ t.Fatal("first worker did not start")
72+ }
73+ if second.Status() != StatusPending || third.Status() != StatusPending {
74+ t.Fatalf("queued statuses = %s/%s", second.Status(), third.Status())
75+ }
76+ if got := atomic.LoadInt32(&maxActive); got > 1 {
77+ t.Fatalf("max active = %d, want <= 1", got)
78+ }
79+
80+ if err := f.Stop(first.ID); err != nil {
81+ t.Fatal(err)
82+ }
83+ first.Wait()
84+ select {
85+ case got := <-started:
86+ if got != second.ID {
87+ t.Fatalf("second started = %q, want %q", got, second.ID)
88+ }
89+ case <-time.After(time.Second):
90+ t.Fatal("scheduler did not release capacity")
91+ }
92+ f.StopAll()
93+ if got := atomic.LoadInt32(&maxActive); got > 1 {
94+ t.Fatalf("max active = %d after release, want <= 1", got)
95+ }
96+}
97+
98+func TestSchedulerRejectsChildSpawningAndTotalBudget(t *testing.T) {
99+ f := New(Config{Root: t.TempDir(), RepoRoot: t.TempDir(), Policy: SubagentPolicy{MaxTotalSpawned: 1}, NewRunner: func(*Agent) Runner {
100+ return RunnerFunc(func(context.Context, Sink) error { return nil })
101+ }})
102+ if _, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "child", RequesterAgentID: "worker-1"}); err == nil {
103+ t.Fatal("child-originated spawn was accepted")
104+ }
105+ first, err := f.Spawn(context.Background(), "one")
106+ if err != nil {
107+ t.Fatal(err)
108+ }
109+ first.Wait()
110+ if _, err := f.Spawn(context.Background(), "two"); err == nil || !strings.Contains(err.Error(), "budget") {
111+ t.Fatalf("second spawn error = %v, want budget rejection", err)
112+ }
113+}
114+
115+func TestStopQueuedAgentBeforeRunnerAdmission(t *testing.T) {
116+ root := t.TempDir()
117+ started := make(chan string, 2)
118+ f := New(Config{
119+ Root: root, RepoRoot: root,
120+ Policy: SubagentPolicy{MaxConcurrent: 1, MaxTotalSpawned: 3, IdleTimeout: time.Hour},
121+ NewRunner: func(a *Agent) Runner {
122+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
123+ started <- a.ID
124+ <-ctx.Done()
125+ return ctx.Err()
126+ })
127+ },
128+ })
129+ first, err := f.Spawn(context.Background(), "first")
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ select {
134+ case got := <-started:
135+ if got != first.ID {
136+ t.Fatalf("first runner = %q, want %q", got, first.ID)
137+ }
138+ case <-time.After(time.Second):
139+ t.Fatal("first runner did not start")
140+ }
141+ queued, err := f.Spawn(context.Background(), "queued")
142+ if err != nil {
143+ t.Fatal(err)
144+ }
145+ if err := f.Stop(queued.ID); err != nil {
146+ t.Fatal(err)
147+ }
148+ queued.Wait()
149+ if queued.Status() != StatusKilled {
150+ t.Fatalf("queued status = %s, want killed", queued.Status())
151+ }
152+ select {
153+ case got := <-started:
154+ t.Fatalf("stopped queued agent still reached runner: %q", got)
155+ case <-time.After(50 * time.Millisecond):
156+ }
157+ if err := f.Stop(first.ID); err != nil {
158+ t.Fatal(err)
159+ }
160+ first.Wait()
161+}
162+
163+func TestCanceledQueuedAgentIsRemovedAndGetsResult(t *testing.T) {
164+ root := t.TempDir()
165+ started := make(chan struct{})
166+ f := New(Config{
167+ Root: root, RepoRoot: root,
168+ Policy: SubagentPolicy{MaxConcurrent: 1, MaxTotalSpawned: 4, IdleTimeout: time.Hour},
169+ NewRunner: func(*Agent) Runner {
170+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
171+ select {
172+ case started <- struct{}{}:
173+ default:
174+ }
175+ <-ctx.Done()
176+ return ctx.Err()
177+ })
178+ },
179+ })
180+ first, err := f.Spawn(context.Background(), "first")
181+ if err != nil {
182+ t.Fatal(err)
183+ }
184+ <-started
185+ queued, err := f.Spawn(context.Background(), "queued")
186+ if err != nil {
187+ t.Fatal(err)
188+ }
189+ if queued.Status() != StatusPending {
190+ t.Fatalf("queued status = %s, want pending", queued.Status())
191+ }
192+ if err := f.Stop(queued.ID); err != nil {
193+ t.Fatal(err)
194+ }
195+ queued.Wait()
196+ f.mu.Lock()
197+ queueLen := len(f.queue)
198+ f.mu.Unlock()
199+ if queueLen != 0 {
200+ t.Fatalf("canceled queue entries = %d, want 0", queueLen)
201+ }
202+ result, err := f.ReadResult(queued.ID)
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ if result.Status != ResultCanceled {
207+ t.Fatalf("queued cancellation result = %s, want canceled", result.Status)
208+ }
209+ if err := f.Stop(first.ID); err != nil {
210+ t.Fatal(err)
211+ }
212+ first.Wait()
213+}
214+
215+func TestSpawnIDsRemainUniqueWithSameClock(t *testing.T) {
216+ now := time.Unix(123, 0)
217+ f := New(Config{
218+ Root: t.TempDir(), RepoRoot: t.TempDir(), Now: func() time.Time { return now },
219+ NewRunner: func(*Agent) Runner {
220+ return RunnerFunc(func(context.Context, Sink) error { return nil })
221+ },
222+ })
223+ first, err := f.Spawn(context.Background(), "same task")
224+ if err != nil {
225+ t.Fatal(err)
226+ }
227+ second, err := f.Spawn(context.Background(), "same task")
228+ if err != nil {
229+ t.Fatal(err)
230+ }
231+ first.Wait()
232+ second.Wait()
233+ if first.ID == second.ID {
234+ t.Fatalf("duplicate agent ids: %q", first.ID)
235+ }
236+}
237+
238+func TestStructuredFallbackResultIsDurable(t *testing.T) {
239+ root := t.TempDir()
240+ f := New(Config{
241+ Root: root, RepoRoot: root,
242+ NewRunner: func(*Agent) Runner {
243+ return RunnerFunc(func(context.Context, Sink) error {
244+ return nil
245+ })
246+ },
247+ })
248+ a, err := f.Spawn(context.Background(), "result fallback")
249+ if err != nil {
250+ t.Fatal(err)
251+ }
252+ a.Wait()
253+ result, err := f.ReadResult(a.ID)
254+ if err != nil {
255+ t.Fatal(err)
256+ }
257+ if result.Status != ResultSucceeded || result.AgentID != a.ID || result.TurnID == "" {
258+ t.Fatalf("result = %+v", result)
259+ }
260+ if _, err := os.Stat(filepath.Join(root, "agents", a.ID, "result.json")); err != nil {
261+ t.Fatalf("result file missing: %v", err)
262+ }
263+}
R packages/agent/swarm/socketpath.go → packages/agent/subagents/socketpath.go +5 -5 renamed, 91% similar
@@ -1,4 +1,4 @@
1-package swarm
1+package subagents
22
33 import (
44 "crypto/sha1"
@@ -18,7 +18,7 @@ import (
1818 const maxUnixSocketPath = 100
1919
2020 // inboxSocketPath returns a per-agent unix-socket path in a transient
21-// runtime directory. Durable swarm state can live on filesystems such as
21+// runtime directory. Durable subagent state can live on filesystems such as
2222 // NFS, 9p, or shared VM mounts that support regular files but reject unix
2323 // socket nodes, so the socket must not be placed below root.
2424 //
@@ -33,7 +33,7 @@ const maxUnixSocketPath = 100
3333 // stable path across parent and child processes and across Resume calls.
3434 func inboxSocketPath(root, agentID string) (string, error) {
3535 if runtime.GOOS == "windows" {
36- // Keep swarm construction usable for callers with custom runners.
36+ // Keep subagent construction usable for callers with custom runners.
3737 // The production listener still reports that its unix transport is
3838 // unavailable, as it did before runtime-directory probing was added.
3939 return filepath.Join(root, "agents", agentID, "in.sock"), nil
@@ -65,7 +65,7 @@ func inboxSocketPath(root, agentID string) (string, error) {
6565 }
6666
6767 func socketPathInBase(base, root, agentID string) (string, error) {
68- dir := filepath.Join(base, "zot-swarm-"+rootTag(root))
68+ dir := filepath.Join(base, "zot-subagents-"+rootTag(root))
6969 if err := os.MkdirAll(dir, 0o700); err != nil {
7070 return "", fmt.Errorf("socket dir %s: %w", dir, err)
7171 }
@@ -109,7 +109,7 @@ func probeUnixSocket(dir string) error {
109109 return errors.Join(closeErr, removeErr)
110110 }
111111
112-// rootTag returns a stable 8-hex-char tag for the swarm root. Used
112+// rootTag returns a stable 8-hex-char tag for the subagent root. Used
113113 // in the runtime-directory name so two parallel zot instances with
114114 // different roots don't share sockets.
115115 func rootTag(root string) string { return shortHash(root) }
R packages/agent/swarm/socketpath_test.go → packages/agent/subagents/socketpath_test.go +4 -4 renamed, 89% similar
@@ -1,4 +1,4 @@
1-package swarm
1+package subagents
22
33 import (
44 "os"
@@ -10,7 +10,7 @@ import (
1010
1111 func TestInboxSocketPathUsesRuntimeDirectory(t *testing.T) {
1212 if runtime.GOOS == "windows" {
13- t.Skip("swarm inbox transport uses Unix-domain sockets")
13+ t.Skip("subagent inbox transport uses Unix-domain sockets")
1414 }
1515
1616 runtimeDir := shortSocketDir(t)
@@ -31,7 +31,7 @@ func TestInboxSocketPathUsesRuntimeDirectory(t *testing.T) {
3131
3232 func TestInboxSocketPathFallsBackFromUnusableRuntimeDirectory(t *testing.T) {
3333 if runtime.GOOS == "windows" {
34- t.Skip("swarm inbox transport uses Unix-domain sockets")
34+ t.Skip("subagent inbox transport uses Unix-domain sockets")
3535 }
3636
3737 badRuntime := filepath.Join(shortSocketDir(t), "not-a-directory")
@@ -53,7 +53,7 @@ func TestInboxSocketPathFallsBackFromUnusableRuntimeDirectory(t *testing.T) {
5353
5454 func TestInboxSocketPathShortensLongAgentID(t *testing.T) {
5555 if runtime.GOOS == "windows" {
56- t.Skip("swarm inbox transport uses Unix-domain sockets")
56+ t.Skip("subagent inbox transport uses Unix-domain sockets")
5757 }
5858
5959 t.Setenv("XDG_RUNTIME_DIR", shortSocketDir(t))
A packages/agent/subagents/supervisor.go +1190 -0
@@ -0,0 +1,1190 @@
1+// Package subagents implements zot's subagent supervisor.
2+//
3+// A Supervisor manages headless, long-lived zot subprocesses and keeps their
4+// process/turn state, event logs, sessions, results, and optional worktree
5+// artifacts durable across supervisor restarts.
6+//
7+// Shared workspaces preserve the historical behavior, while an explicit
8+// worktree request gives a child an isolated checkout whose patch is captured
9+// before cleanup. The Runner abstraction lets tests inject a fake process;
10+// production uses `zot --subagent-worker ...`.
11+package subagents
12+
13+import (
14+ "context"
15+ "errors"
16+ "fmt"
17+ "os"
18+ "path/filepath"
19+ "sort"
20+ "strings"
21+ "sync"
22+ "sync/atomic"
23+ "time"
24+)
25+
26+// Status is the high-level lifecycle state of an Agent.
27+type Status string
28+
29+const (
30+ StatusPending Status = "pending" // created, not started yet
31+ StatusRunning Status = "running" // Runner.Run is in flight
32+ StatusDone Status = "done" // Runner.Run returned nil
33+ StatusFailed Status = "failed" // Runner.Run returned an error
34+ StatusKilled Status = "killed" // Stop() called before completion
35+ StatusDetached Status = "detached" // reloaded from disk; no live runner
36+)
37+
38+// Config configures a Supervisor.
39+type Config struct {
40+ // Context is the supervisor lifetime. Child workers use it as their
41+ // parent context rather than inheriting an individual tool/model-turn
42+ // context. A nil value means context.Background().
43+ Context context.Context
44+
45+ // Root is the directory under which per-agent state files live.
46+ // Typically <ZotHome>/subagents, but tests pass a tempdir.
47+ Root string
48+
49+ // Policy owns concurrency, timeout, output, and path safety limits.
50+ Policy SubagentPolicy
51+
52+ // RepoRoot is the repository used by spawned agents. Shared-mode children
53+ // run here; worktree-mode children use an isolated checkout created from
54+ // this root.
55+ RepoRoot string
56+
57+ // Provider is the active host provider. It is used only to decide whether
58+ // an explicit child provider may inherit the host endpoint settings.
59+ Provider string
60+
61+ // FastMode is copied to every newly spawned child and persisted so
62+ // child subprocesses use the same OpenAI service-tier setting.
63+ FastMode bool
64+
65+ // BaseURL and InsecureTLS are the effective provider connection
66+ // settings inherited by newly spawned children. They are copied to
67+ // each Agent and persisted so a reload/resume does not silently fall
68+ // back to the child's independent provider configuration.
69+ BaseURL string
70+ InsecureTLS bool
71+
72+ // NewRunner produces the Runner for an Agent. If nil, the default
73+ // `zot --subagent-worker ...` exec runner is used. Tests inject a fake
74+ // here.
75+ NewRunner func(a *Agent) Runner
76+
77+ // ResolveCredential resolves the credential inherited by a child
78+ // process. The default runner transfers it over the child's stdin,
79+ // keeping command-backed credentials out of argv and the environment.
80+ ResolveCredential func(ctx context.Context, provider string) (Credential, error)
81+
82+ // Now is a clock seam for tests; defaults to time.Now.
83+ Now func() time.Time
84+}
85+
86+// Credential is the resolved authentication state inherited by a subagent child.
87+type Credential struct {
88+ Value string `json:"value"`
89+ Method string `json:"method"`
90+ AccountID string `json:"account_id,omitempty"`
91+}
92+
93+// Runner executes one agent task. Run blocks until the task finishes,
94+// is cancelled via ctx, or hits an unrecoverable error.
95+//
96+// Run should report progress by writing short human-readable strings
97+// to the activity channel and final transcript text to transcript.
98+// Both channels are non-blocking sinks owned by the Supervisor; if the
99+// dashboard isn't reading, sends are dropped.
100+type Runner interface {
101+ Run(ctx context.Context, sink Sink) error
102+}
103+
104+// Sink is how a Runner reports activity and transcript back to the
105+// supervisor. All methods are safe to call from any goroutine and
106+// never block.
107+type Sink interface {
108+ // Activity sets the one-line "what is this agent doing right now"
109+ // string shown in the dashboard.
110+ Activity(msg string)
111+ // Transcript appends a chunk of agent output (typically a final
112+ // assistant message) to the agent's running transcript.
113+ Transcript(chunk string)
114+}
115+
116+// Supervisor supervises a set of Agents.
117+type Supervisor struct {
118+ cfg Config
119+
120+ mu sync.Mutex
121+ operationMu sync.Mutex
122+ lifetimeCtx context.Context
123+ lifetimeCancel context.CancelFunc
124+ agents map[string]*Agent
125+ order []string // creation order for stable listing
126+ queue []*Agent
127+ active int
128+ activeByParent map[string]int
129+ activeByBatch map[string]int
130+ totalSpawned int
131+ batches map[string]*Batch
132+
133+ // activeSession is the host session id the dashboard is
134+ // currently scoped to. When non-empty, SnapshotAll filters out
135+ // agents whose SessionID doesn't match (unscoped
136+ // agents with SessionID == "" are always shown). Spawn stamps
137+ // new agents with this value so they appear only in the session
138+ // that created them. When empty (the default), the historical
139+ // "show everything" behaviour is preserved — important for
140+ // tests and any scripted use of the Supervisor that doesn't bother
141+ // with sessions.
142+ activeSession string
143+}
144+
145+// New constructs a Supervisor from cfg. Missing config fields are filled
146+// with defaults.
147+func New(cfg Config) *Supervisor {
148+ if cfg.Now == nil {
149+ cfg.Now = time.Now
150+ }
151+ if cfg.NewRunner == nil {
152+ cfg.NewRunner = func(a *Agent) Runner {
153+ return &execRunner{agent: a, resolveCredential: cfg.ResolveCredential}
154+ }
155+ }
156+ cfg.Policy.normalize()
157+ lifetimeParent := cfg.Context
158+ if lifetimeParent == nil {
159+ lifetimeParent = context.Background()
160+ }
161+ lifetimeCtx, lifetimeCancel := context.WithCancel(lifetimeParent)
162+ return &Supervisor{
163+ cfg: cfg,
164+ lifetimeCtx: lifetimeCtx,
165+ lifetimeCancel: lifetimeCancel,
166+ agents: map[string]*Agent{},
167+ activeByParent: map[string]int{},
168+ activeByBatch: map[string]int{},
169+ batches: map[string]*Batch{},
170+ }
171+}
172+
173+// workerContext derives a child worker context from the supervisor lifetime.
174+// A positive timeout limits the worker lifetime; otherwise it is cancellable
175+// only when the supervisor shuts down or the agent is stopped.
176+func (f *Supervisor) workerContext(timeout time.Duration) (context.Context, context.CancelFunc) {
177+ base := f.lifetimeCtx
178+ if base == nil {
179+ base = context.Background()
180+ }
181+ if timeout > 0 {
182+ return context.WithTimeout(base, timeout)
183+ }
184+ return context.WithCancel(base)
185+}
186+
187+// SetRepoRoot updates the shared working directory for subsequently
188+// spawned agents. Existing agents keep the directory they were started
189+// with; callers use this when the host session changes cwd.
190+func (f *Supervisor) SetRepoRoot(root string) {
191+ f.mu.Lock()
192+ defer f.mu.Unlock()
193+ f.cfg.RepoRoot = root
194+}
195+
196+// RepoRoot returns the working directory used by subsequently spawned
197+// agents.
198+func (f *Supervisor) RepoRoot() string {
199+ f.mu.Lock()
200+ defer f.mu.Unlock()
201+ return f.cfg.RepoRoot
202+}
203+
204+// SetFastMode updates the fast-mode setting for subsequently spawned
205+// children. Existing agents keep the setting captured at their spawn
206+// boundary so resumes remain deterministic.
207+func (f *Supervisor) SetFastMode(enabled bool) {
208+ f.mu.Lock()
209+ f.cfg.FastMode = enabled
210+ f.mu.Unlock()
211+}
212+
213+// FastMode reports the setting used for subsequently spawned children.
214+func (f *Supervisor) FastMode() bool {
215+ f.mu.Lock()
216+ defer f.mu.Unlock()
217+ return f.cfg.FastMode
218+}
219+
220+// SetProvider updates the provider used by subsequently spawned children.
221+// Existing agents keep their persisted provider selection.
222+func (f *Supervisor) SetProvider(provider string) {
223+ f.mu.Lock()
224+ f.cfg.Provider = strings.TrimSpace(provider)
225+ f.mu.Unlock()
226+}
227+
228+// SetProviderSettings updates the effective connection settings used by
229+// subsequently spawned children. Existing agents keep their persisted values
230+// so a model/provider switch cannot silently change a running or resumed
231+// worker's endpoint.
232+func (f *Supervisor) SetProviderSettings(baseURL string, insecureTLS bool) {
233+ f.mu.Lock()
234+ f.cfg.BaseURL = strings.TrimSpace(baseURL)
235+ f.cfg.InsecureTLS = insecureTLS
236+ f.mu.Unlock()
237+}
238+
239+// ProviderSettings returns the settings used by subsequently spawned
240+// children. It is primarily useful to host integrations that need to verify
241+// the manager follows the active provider selection.
242+func (f *Supervisor) ProviderSettings() (string, bool) {
243+ f.mu.Lock()
244+ defer f.mu.Unlock()
245+ return f.cfg.BaseURL, f.cfg.InsecureTLS
246+}
247+
248+// SetActiveSession scopes the dashboard view (and Spawn stamping)
249+// to a particular host zot session id. Pass empty to clear the
250+// scope and revert to "show every agent" (the original behaviour).
251+//
252+// Existing in-memory agents keep their SessionID; only the filter
253+// applied at snapshot time changes. So swapping the active session
254+// with /sessions instantly re-narrows the dashboard without
255+// touching any agent state.
256+func (f *Supervisor) SetActiveSession(id string) {
257+ f.mu.Lock()
258+ defer f.mu.Unlock()
259+ f.activeSession = id
260+}
261+
262+// ActiveSession returns the current scope, mostly for tests and
263+// diagnostics. Empty means "no scope; show everything".
264+func (f *Supervisor) ActiveSession() string {
265+ f.mu.Lock()
266+ defer f.mu.Unlock()
267+ return f.activeSession
268+}
269+
270+// agentStateDir is the per-agent state directory laid out as:
271+//
272+// <root>/agents/<id>/
273+// events.jsonl durable event log (runner-owned)
274+// session.json persistent agent session (child-owned)
275+// meta.json static metadata (id, task)
276+//
277+// The transient unix socket inbox lives in a probed runtime directory,
278+// which may be on a different filesystem from this durable state.
279+func (f *Supervisor) agentStateDir(id string) string {
280+ return filepath.Join(f.cfg.Root, "agents", id)
281+}
282+
283+func (f *Supervisor) uniqueAgentIDLocked(base string) string {
284+ id := base
285+ for suffix := 2; ; suffix++ {
286+ _, inMemory := f.agents[id]
287+ _, onDiskErr := os.Stat(f.agentStateDir(id))
288+ if !inMemory && onDiskErr != nil {
289+ return id
290+ }
291+ id = fmt.Sprintf("%s-%d", base, suffix)
292+ }
293+}
294+
295+// SpawnRequest configures a Spawn. Only Task is required; the rest
296+// are optional. Model + Provider, when set, get baked into the
297+// child argv as --model / --provider so the agent runs against the
298+// chosen model regardless of the parent's current selection. A named
299+// Subagent is passed to the child as --subagent, where it resolves the
300+// markdown profile and applies its instructions. Reasoning is passed
301+// as --reasoning when set. FastMode restricts the host setting when a
302+// selected profile declares a fastMode preference.
303+type SpawnRequest struct {
304+ Task string
305+ Model string // optional override; child resolves default if empty
306+ Provider string // optional override; usually paired with Model
307+ BaseURL string // optional provider endpoint override
308+ // InsecureTLS enables the child to skip TLS verification for its
309+ // effective custom provider endpoint.
310+ InsecureTLS bool
311+ Reasoning string // optional reasoning/thinking level override
312+ // FastMode is an optional profile restriction. A non-nil false value
313+ // disables fast mode for this child; true still cannot enable fast mode
314+ // when Config.FastMode is false. Nil inherits Config.FastMode.
315+ FastMode *bool
316+ Subagent string // optional named markdown profile
317+
318+ // ParentID is metadata used for per-parent scheduling limits. A
319+ // non-empty RequesterAgentID identifies an untrusted child request;
320+ // recursive spawning is rejected in v1.
321+ ParentID string
322+ BatchID string
323+ RootSessionID string
324+ RequesterAgentID string
325+
326+ Timeout time.Duration
327+ MaxTurns int
328+ Tools []string
329+ WorkspaceMode WorkspaceMode
330+ WorkspaceBase string
331+ WorkspaceCapture CaptureMode
332+}
333+
334+// Spawn creates a new Agent for the given task, allocates its on-disk
335+// state directory (events log, inbox socket path, session file path), and
336+// queues it for the manager-owned scheduler. The returned Agent is pending
337+// when the concurrency budget is full and transitions to running when a slot
338+// opens. This is the historical signature; callers that want to override the
339+// child's model or workspace use SpawnReq instead.
340+func (f *Supervisor) Spawn(ctx context.Context, task string) (*Agent, error) {
341+ return f.SpawnReq(ctx, SpawnRequest{Task: task})
342+}
343+
344+// SpawnReq is the full-fat variant of Spawn that accepts a SpawnRequest.
345+// Existing callers can keep using Spawn; new code that wants to pin the
346+// child's model, policy, or workspace uses this.
347+func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) {
348+ task := strings.TrimSpace(req.Task)
349+ if task == "" {
350+ return nil, errors.New("subagents: empty task")
351+ }
352+ if req.RequesterAgentID != "" {
353+ return nil, errors.New("subagents: child-originated spawning is disabled")
354+ }
355+ if err := f.validateSpawnRequest(req); err != nil {
356+ return nil, err
357+ }
358+ if ctx == nil {
359+ ctx = context.Background()
360+ }
361+
362+ now := f.cfg.Now()
363+ baseID := newAgentID(task, now)
364+
365+ // Reserve a spawn budget before touching disk. This makes the total
366+ // budget race-free across slash commands, tools, and batch callers.
367+ f.mu.Lock()
368+ id := f.uniqueAgentIDLocked(baseID)
369+ if f.totalSpawned >= f.cfg.Policy.MaxTotalSpawned {
370+ f.mu.Unlock()
371+ return nil, fmt.Errorf("subagents: total spawn budget exhausted (%d)", f.cfg.Policy.MaxTotalSpawned)
372+ }
373+ f.totalSpawned++
374+ dir := f.cfg.RepoRoot
375+ fastMode := f.cfg.FastMode
376+ configProvider := f.cfg.Provider
377+ configBaseURL := f.cfg.BaseURL
378+ configInsecureTLS := f.cfg.InsecureTLS
379+ sessionID := f.activeSession
380+ f.mu.Unlock()
381+ if req.FastMode != nil {
382+ fastMode = fastMode && *req.FastMode
383+ }
384+ childProvider := strings.TrimSpace(req.Provider)
385+ inheritProviderSettings := childProvider == "" || configProvider == "" || strings.EqualFold(childProvider, strings.TrimSpace(configProvider))
386+ baseURL := strings.TrimSpace(req.BaseURL)
387+ if baseURL == "" && inheritProviderSettings {
388+ baseURL = strings.TrimSpace(configBaseURL)
389+ }
390+ insecureTLS := req.InsecureTLS || (inheritProviderSettings && configInsecureTLS)
391+ reserved := true
392+ defer func() {
393+ if reserved {
394+ f.mu.Lock()
395+ f.totalSpawned--
396+ f.mu.Unlock()
397+ }
398+ }()
399+
400+ stateDir := f.agentStateDir(id)
401+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
402+ return nil, fmt.Errorf("subagents state dir: %w", err)
403+ }
404+ lease, err := acquireAgentLease(stateDir)
405+ if err != nil {
406+ return nil, fmt.Errorf("subagents agent lease: %w", err)
407+ }
408+ leaseOwned := true
409+ defer func() {
410+ if leaseOwned {
411+ _ = lease.Close()
412+ }
413+ }()
414+ logPath := filepath.Join(stateDir, "events.jsonl")
415+ sessionPath := filepath.Join(stateDir, "session.json")
416+ // Keep the transient unix socket outside the durable state root.
417+ // ZOT_HOME may live on a shared or network filesystem that supports
418+ // regular state files but cannot host unix socket nodes.
419+ inboxPath, err := inboxSocketPath(f.cfg.Root, id)
420+ if err != nil {
421+ _ = lease.Close()
422+ leaseOwned = false
423+ _ = os.RemoveAll(stateDir)
424+ return nil, fmt.Errorf("subagents inbox path: %w", err)
425+ }
426+
427+ workspaceMode := req.WorkspaceMode
428+ if workspaceMode == "" {
429+ workspaceMode = WorkspaceShared
430+ }
431+ workspace, err := PrepareWorkspace(ctx, WorkspaceRequest{
432+ Mode: workspaceMode,
433+ RepositoryRoot: dir,
434+ StateDir: stateDir,
435+ AgentID: id,
436+ Base: req.WorkspaceBase,
437+ Capture: req.WorkspaceCapture,
438+ AllowedRoots: append([]string(nil), f.cfg.Policy.AllowedRoots...),
439+ })
440+ if err != nil {
441+ _ = lease.Close()
442+ leaseOwned = false
443+ _ = os.RemoveAll(stateDir)
444+ return nil, err
445+ }
446+ dir = workspace.Dir()
447+ maxTurns := req.MaxTurns
448+ if maxTurns <= 0 {
449+ maxTurns = f.cfg.Policy.MaxTurns
450+ }
451+ timeout := req.Timeout
452+ if timeout <= 0 {
453+ timeout = f.cfg.Policy.DefaultTimeout
454+ }
455+ runCtx, cancel := f.workerContext(timeout)
456+ tools := append([]string(nil), req.Tools...)
457+ if len(tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 {
458+ tools = append([]string(nil), f.cfg.Policy.AllowedTools...)
459+ }
460+
461+ a := &Agent{
462+ ID: id,
463+ Task: task,
464+ OriginalTask: task,
465+ Dir: dir,
466+ RepositoryRoot: workspace.RepositoryRoot(),
467+ WorkspacePath: workspace.Dir(),
468+ Started: now,
469+ ParentID: strings.TrimSpace(req.ParentID),
470+ BatchID: strings.TrimSpace(req.BatchID),
471+ RootSessionID: strings.TrimSpace(req.RootSessionID),
472+ Model: strings.TrimSpace(req.Model),
473+ Provider: strings.TrimSpace(req.Provider),
474+ BaseURL: baseURL,
475+ InsecureTLS: insecureTLS,
476+ Reasoning: strings.TrimSpace(req.Reasoning),
477+ FastMode: fastMode,
478+ Subagent: strings.TrimSpace(req.Subagent),
479+ SessionID: sessionID,
480+ WorkspaceMode: workspaceMode,
481+ WorkspaceBase: strings.TrimSpace(req.WorkspaceBase),
482+ WorkspaceCapture: req.WorkspaceCapture,
483+ MaxTurns: maxTurns,
484+ Timeout: timeout,
485+ HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,
486+ Tools: tools,
487+ InboxPath: inboxPath,
488+ EventLogPath: logPath,
489+ SessionPath: sessionPath,
490+ inbox: NewInbox(inboxPath),
491+ status: StatusPending,
492+ activity: "queued",
493+ processState: ProcessPending,
494+ turnState: TurnQueued,
495+ updatedAt: now,
496+ lastActivity: now,
497+ stateDir: stateDir,
498+ lease: lease,
499+ maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
500+ maxOutputLines: f.cfg.Policy.MaxOutputLines,
501+ done: make(chan struct{}),
502+ turnResults: make(chan *TurnResult, 16),
503+ }
504+ if a.RootSessionID == "" {
505+ a.RootSessionID = sessionID
506+ }
507+ a.ctx, a.cancel = runCtx, cancel
508+ a.persistFn = f.persistAgent
509+ a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
510+ a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
511+ a.runner = f.cfg.NewRunner(a)
512+ if err := writeAgentMeta(stateDir, a); err != nil {
513+ if a.cancel != nil {
514+ a.cancel()
515+ }
516+ _ = workspace.Cleanup(context.Background())
517+ _ = lease.Close()
518+ leaseOwned = false
519+ _ = os.RemoveAll(stateDir)
520+ return nil, fmt.Errorf("subagents initial metadata: %w", err)
521+ }
522+
523+ f.mu.Lock()
524+ f.agents[id] = a
525+ f.order = append(f.order, id)
526+ f.queue = append(f.queue, a)
527+ f.mu.Unlock()
528+ leaseOwned = false
529+
530+ // Persisted metadata was written before queue admission, so a later
531+ // supervisor can reconstruct work that never reached a process.
532+ f.armQueueTimeout(a)
533+ f.schedule()
534+ reserved = false
535+ return a, nil
536+}
537+
538+// SendUserTurn is sugar for the common "send the next user turn"
539+// case. It quotes nothing and forwards verbatim; callers are
540+// expected to have already trimmed and expanded the text.
541+// CancelTurn requests cancellation of the current turn while keeping the
542+// worker process alive for a later follow-up.
543+func (f *Supervisor) CancelTurn(id string) error {
544+ a := f.Get(id)
545+ if a == nil {
546+ return fmt.Errorf("subagents: no such agent %q", id)
547+ }
548+ if a.inbox == nil {
549+ return fmt.Errorf("subagents: agent %s has no inbox", a.ID)
550+ }
551+ a.setTurnState(TurnCanceling, a.CurrentTurnID())
552+ return a.inbox.SendCommand(NewCommand(CommandTurnCancel, a.ID, a.CurrentTurnID(), TurnCancelPayload{Reason: "user"}))
553+}
554+
555+func (f *Supervisor) SendUserTurn(id, text string) error {
556+ a := f.Get(id)
557+ if a == nil {
558+ return fmt.Errorf("subagents: no such agent %q", id)
559+ }
560+ if a.inbox == nil {
561+ return fmt.Errorf("subagents: agent %s has no inbox", a.ID)
562+ }
563+ return a.inbox.SendCommand(NewCommand(CommandTurnStart, a.ID, a.CurrentTurnID(), TurnStartPayload{Prompt: text}))
564+}
565+
566+func (f *Supervisor) run(a *Agent) {
567+ a.mu.Lock()
568+ killedBeforeStart := a.status == StatusKilled
569+ ctxErr := error(nil)
570+ if a.ctx != nil {
571+ ctxErr = a.ctx.Err()
572+ }
573+ a.mu.Unlock()
574+ var err error
575+ if killedBeforeStart || ctxErr != nil || !atomic.CompareAndSwapInt32(&a.launchState, 0, 1) {
576+ if ctxErr != nil {
577+ err = ctxErr
578+ } else {
579+ err = context.Canceled
580+ }
581+ } else {
582+ a.setActivity("starting")
583+ if a.runner == nil {
584+ err = errors.New("subagents: agent has no runner")
585+ } else {
586+ err = a.runner.Run(a.ctx, agentSink{a: a})
587+ }
588+ }
589+
590+ a.mu.Lock()
591+ a.finished = f.cfg.Now()
592+ switch {
593+ case a.status == StatusKilled:
594+ // Stop requested a terminal state; preserve it.
595+ case errors.Is(err, context.Canceled):
596+ a.status = StatusKilled
597+ a.activity = "cancelled"
598+ a.lastErr = err
599+ case errors.Is(err, context.DeadlineExceeded):
600+ a.status = StatusFailed
601+ a.activity = "timeout"
602+ a.lastErr = err
603+ case err != nil:
604+ a.status = StatusFailed
605+ a.activity = "error: " + truncate(err.Error(), 120)
606+ a.lastErr = err
607+ default:
608+ a.status = StatusDone
609+ a.activity = "done"
610+ }
611+ finalStatus := a.status
612+ a.mu.Unlock()
613+
614+ switch finalStatus {
615+ case StatusKilled:
616+ a.setProcessState(ProcessKilled)
617+ a.setTurnState(TurnCanceled, a.CurrentTurnID())
618+ case StatusFailed:
619+ a.setProcessState(ProcessExited)
620+ a.setTurnState(TurnFailed, a.CurrentTurnID())
621+ default:
622+ a.setProcessState(ProcessExited)
623+ if a.TurnState() == TurnRunning || a.TurnState() == TurnIdle {
624+ a.setTurnState(TurnSucceeded, a.CurrentTurnID())
625+ }
626+ }
627+
628+ // The process slot is no longer occupied once Runner.Run has returned.
629+ // Release it before potentially slow artifact capture and workspace
630+ // cleanup so queued work can start without waiting on git/filesystem I/O.
631+ f.releaseCapacity(a)
632+
633+ // Capture isolated output and write the structured result before the
634+ // temporary workspace is removed. This ordering is the recovery
635+ // invariant: a user can always inspect a durable result after failure.
636+ f.captureWorkspace(a)
637+ f.ensureResult(a, finalStatus, err)
638+ if a.cancel != nil {
639+ a.cancel()
640+ }
641+ if a.inbox != nil {
642+ _ = a.inbox.Close()
643+ }
644+ a.setProcessPID(0)
645+ f.persistAgent(a)
646+ if a.workspaceCleanup != nil {
647+ _ = a.workspaceCleanup()
648+ }
649+ _ = a.releaseLease()
650+ a.closeDone()
651+}
652+
653+// List returns a snapshot of every agent in creation order. The
654+// returned slice is a copy; callers may iterate without holding the
655+// subagent lock. Agent fields are read under their own mutex during
656+// formatting in Snapshot.
657+func (f *Supervisor) List() []*Agent {
658+ f.mu.Lock()
659+ defer f.mu.Unlock()
660+ out := make([]*Agent, 0, len(f.order))
661+ for _, id := range f.order {
662+ out = append(out, f.agents[id])
663+ }
664+ return out
665+}
666+
667+// Get returns the agent with the given (possibly truncated) id, or
668+// nil. Matching is prefix-based so the user can type the first few
669+// characters of a long id.
670+func (f *Supervisor) Get(id string) *Agent {
671+ f.mu.Lock()
672+ defer f.mu.Unlock()
673+ if a, ok := f.agents[id]; ok {
674+ return a
675+ }
676+ // Prefix match.
677+ var hits []*Agent
678+ for k, a := range f.agents {
679+ if strings.HasPrefix(k, id) {
680+ hits = append(hits, a)
681+ }
682+ }
683+ if len(hits) == 1 {
684+ return hits[0]
685+ }
686+ return nil
687+}
688+
689+var errDetachedStopTimeout = errors.New("detached worker stop timeout")
690+
691+const detachedStopPollInterval = 20 * time.Millisecond
692+
693+// waitForDetachedWorker waits for the worker inbox to disappear, returning a
694+// sentinel when the grace period expires. Polling is interruptible so a
695+// caller's context is not made to wait for the next fixed sleep interval.
696+func waitForDetachedWorker(ctx context.Context, path string, timeout time.Duration) error {
697+ if ctx == nil {
698+ ctx = context.Background()
699+ }
700+ timer := time.NewTimer(timeout)
701+ defer timer.Stop()
702+ ticker := time.NewTicker(detachedStopPollInterval)
703+ defer ticker.Stop()
704+ for {
705+ if err := ctx.Err(); err != nil {
706+ return err
707+ }
708+ if !inboxLive(path) {
709+ return nil
710+ }
711+ select {
712+ case <-ctx.Done():
713+ return ctx.Err()
714+ case <-timer.C:
715+ if !inboxLive(path) {
716+ return nil
717+ }
718+ return errDetachedStopTimeout
719+ case <-ticker.C:
720+ }
721+ }
722+}
723+
724+// Stop cancels the agent's context. The Runner should observe the
725+// cancellation and return promptly; the goroutine then finalises the
726+// agent in StatusKilled. Also closes the supervisor-side inbox handle
727+// so any pending send retries fail fast instead of dialing a
728+// socket about to be unlinked. Stop uses a background context; callers
729+// that need cancellation while waiting for a detached worker should use
730+// StopContext.
731+//
732+// Stop is a no-op for any agent that's not in a live runnable state
733+// — Done / Failed / Killed (already finalised). Reloaded Detached agents
734+// are probed first: if their worker survived the supervisor, Stop sends the
735+// shutdown command through the durable inbox instead of pretending no
736+// process exists.
737+func (f *Supervisor) Stop(id string) error {
738+ return f.stop(context.Background(), id)
739+}
740+
741+// StopContext is Stop with a context that can interrupt detached-worker
742+// shutdown and its graceful and force-stop waits. The context is not held
743+// under the supervisor operation lock while those waits run.
744+func (f *Supervisor) StopContext(ctx context.Context, id string) error {
745+ return f.stop(ctx, id)
746+}
747+
748+func (f *Supervisor) stop(ctx context.Context, id string) error {
749+ if ctx == nil {
750+ ctx = context.Background()
751+ }
752+ f.operationMu.Lock()
753+ a := f.Get(id)
754+ if a == nil {
755+ f.operationMu.Unlock()
756+ return fmt.Errorf("no such agent %q", id)
757+ }
758+
759+ // Coordinate the status transition with schedule. Holding f.mu while
760+ // checking and changing a.status closes the small window where Stop could
761+ // observe pending, schedule could promote the same agent to running, and
762+ // the pending cleanup below could then finalize an agent that was already
763+ // admitted to the runner.
764+ f.mu.Lock()
765+ a.mu.Lock()
766+ status := a.status
767+ if status == StatusDone || status == StatusFailed || status == StatusKilled {
768+ a.mu.Unlock()
769+ f.mu.Unlock()
770+ f.operationMu.Unlock()
771+ return nil
772+ }
773+ if status == StatusDetached {
774+ path := a.InboxPath
775+ inbox := a.inbox
776+ pid := a.ProcessPIDValue()
777+ a.mu.Unlock()
778+ f.mu.Unlock()
779+ // The detached worker may take the full grace period to exit. Do not
780+ // serialize unrelated supervisor operations for that entire wait.
781+ f.operationMu.Unlock()
782+ return f.stopDetached(ctx, a, path, inbox, pid)
783+ }
784+ a.status = StatusKilled
785+ a.activity = "canceling"
786+ atomic.CompareAndSwapInt32(&a.launchState, 0, 2)
787+ if status == StatusPending {
788+ for i, queued := range f.queue {
789+ if queued == a {
790+ f.queue = append(f.queue[:i], f.queue[i+1:]...)
791+ break
792+ }
793+ }
794+ }
795+ a.mu.Unlock()
796+ f.mu.Unlock()
797+ f.operationMu.Unlock()
798+ a.setTurnState(TurnCanceling, a.CurrentTurnID())
799+
800+ if status == StatusPending {
801+ a.setProcessState(ProcessKilled)
802+ a.setTurnState(TurnCanceled, a.CurrentTurnID())
803+ f.captureWorkspace(a)
804+ f.ensureResult(a, StatusKilled, context.Canceled)
805+ if a.cancel != nil {
806+ a.cancel()
807+ }
808+ f.persistAgent(a)
809+ if a.workspaceCleanup != nil {
810+ _ = a.workspaceCleanup()
811+ }
812+ _ = a.releaseLease()
813+ a.closeDone()
814+ f.schedule()
815+ if err := ctx.Err(); err != nil {
816+ return err
817+ }
818+ return nil
819+ }
820+
821+ // Ask a live worker to shut down cleanly first. If the socket is not
822+ // ready, fall back immediately; a forceful context cancellation is
823+ // safer than leaving a supervisor entry running forever.
824+ if err := ctx.Err(); err != nil {
825+ if a.cancel != nil {
826+ a.cancel()
827+ }
828+ return err
829+ }
830+ graceful := false
831+ if a.inbox != nil {
832+ err := a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{}))
833+ if err == nil {
834+ graceful = true
835+ }
836+ }
837+ if !graceful && a.cancel != nil {
838+ a.cancel()
839+ }
840+ if graceful {
841+ grace := f.cfg.Policy.CancelGracePeriod
842+ if grace <= 0 {
843+ grace = 10 * time.Second
844+ }
845+ go func() {
846+ timer := time.NewTimer(grace)
847+ defer timer.Stop()
848+ select {
849+ case <-a.done:
850+ case <-timer.C:
851+ if a.cancel != nil {
852+ a.cancel()
853+ }
854+ if a.inbox != nil {
855+ _ = a.inbox.Close()
856+ }
857+ }
858+ }()
859+ }
860+ if err := ctx.Err(); err != nil {
861+ if a.cancel != nil {
862+ a.cancel()
863+ }
864+ if a.inbox != nil {
865+ _ = a.inbox.Close()
866+ }
867+ return err
868+ }
869+ return nil
870+}
871+
872+func (f *Supervisor) stopDetached(ctx context.Context, a *Agent, path string, inbox *Inbox, pid int) error {
873+ if inbox == nil {
874+ return nil
875+ }
876+ if err := ctx.Err(); err != nil {
877+ return err
878+ }
879+ if !inboxLive(path) {
880+ return nil
881+ }
882+ if err := ctx.Err(); err != nil {
883+ return err
884+ }
885+ if err := inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil {
886+ if ctxErr := ctx.Err(); ctxErr != nil {
887+ return ctxErr
888+ }
889+ return err
890+ }
891+ a.setActivity("shutdown requested")
892+ grace := f.cfg.Policy.CancelGracePeriod
893+ if grace <= 0 {
894+ grace = 10 * time.Second
895+ }
896+ waitErr := waitForDetachedWorker(ctx, path, grace)
897+ if waitErr != nil && !errors.Is(waitErr, errDetachedStopTimeout) {
898+ return waitErr
899+ }
900+ if waitErr == nil {
901+ f.markDetachedKilled(a)
902+ return nil
903+ }
904+ if err := ctx.Err(); err != nil {
905+ return err
906+ }
907+ if pid <= 0 {
908+ return fmt.Errorf("agent %s live worker did not stop within %s and has no recorded pid", a.ID, grace)
909+ }
910+ if killErr := forceKillProcess(pid); killErr != nil {
911+ return fmt.Errorf("agent %s force-stop: %w", a.ID, killErr)
912+ }
913+ if waitErr := waitForDetachedWorker(ctx, path, time.Second); waitErr != nil {
914+ if errors.Is(waitErr, errDetachedStopTimeout) {
915+ return fmt.Errorf("agent %s live worker did not stop after force-stop", a.ID)
916+ }
917+ return waitErr
918+ }
919+ f.markDetachedKilled(a)
920+ return nil
921+}
922+
923+func (f *Supervisor) markDetachedKilled(a *Agent) {
924+ f.operationMu.Lock()
925+ defer f.operationMu.Unlock()
926+ a.mu.Lock()
927+ a.status = StatusKilled
928+ a.activity = "killed"
929+ a.mu.Unlock()
930+ a.setProcessState(ProcessKilled)
931+ a.setTurnState(TurnCanceled, a.CurrentTurnID())
932+ a.setProcessPID(0)
933+ f.persistAgent(a)
934+}
935+
936+// StopAll cancels every running agent. Used on shutdown.
937+func (f *Supervisor) StopAll() {
938+ _ = f.stopAll(context.Background())
939+}
940+
941+// StopAllContext is the context-aware shutdown variant. It returns the
942+// context error when cancellation interrupts a detached-worker wait.
943+func (f *Supervisor) StopAllContext(ctx context.Context) error {
944+ return f.stopAll(ctx)
945+}
946+
947+func (f *Supervisor) stopAll(ctx context.Context) error {
948+ if ctx == nil {
949+ ctx = context.Background()
950+ }
951+ agents := f.List()
952+ var firstErr error
953+ for _, a := range agents {
954+ if err := f.stop(ctx, a.ID); err != nil && firstErr == nil {
955+ firstErr = err
956+ }
957+ }
958+ defer func() {
959+ if f.lifetimeCancel != nil {
960+ f.lifetimeCancel()
961+ }
962+ }()
963+ for _, a := range agents {
964+ select {
965+ case <-a.done:
966+ case <-ctx.Done():
967+ if firstErr == nil {
968+ firstErr = ctx.Err()
969+ }
970+ return firstErr
971+ }
972+ }
973+ return firstErr
974+}
975+
976+// Remove tears down the per-agent state for a terminated agent. It
977+// is an error to remove an agent that's still running; call Stop
978+// first and wait for the status to settle. Detached agents
979+// (reloaded from disk) remove cleanly because they have no live
980+// runner racing for the same files.
981+//
982+// Shared-workspace agents never touch source files during Remove; worktree
983+// agents have already captured their output and removed the isolated checkout
984+// during finalization. Remove then deletes the agent's durable state directory
985+// under <root>/agents/<id>/.
986+func (f *Supervisor) Remove(id string) error {
987+ f.operationMu.Lock()
988+ defer f.operationMu.Unlock()
989+ a := f.Get(id)
990+ if a == nil {
991+ return fmt.Errorf("no such agent %q", id)
992+ }
993+ a.mu.Lock()
994+ st := a.status
995+ inboxPath := a.InboxPath
996+ a.mu.Unlock()
997+ if st == StatusRunning || st == StatusPending {
998+ return fmt.Errorf("agent %s still %s", a.ID, st)
999+ }
1000+ if inboxLive(inboxPath) {
1001+ return fmt.Errorf("agent %s still has a live worker; stop it first", a.ID)
1002+ }
1003+ select {
1004+ case <-a.done:
1005+ default:
1006+ return fmt.Errorf("agent %s is still finalizing", a.ID)
1007+ }
1008+ lease, err := acquireAgentLease(a.stateDirectory(f.cfg.Root))
1009+ if err != nil {
1010+ return fmt.Errorf("agent %s state is owned by another supervisor: %w", a.ID, err)
1011+ }
1012+ _ = lease.Close()
1013+ // Best-effort cleanup of the per-agent state directory
1014+ // (meta.json, events.jsonl, session.json). Failing here would leave
1015+ // the user with no recourse,
1016+ // so swallow the error.
1017+ _ = os.RemoveAll(a.stateDirectory(f.cfg.Root))
1018+ f.mu.Lock()
1019+ delete(f.agents, a.ID)
1020+ for i, k := range f.order {
1021+ if k == a.ID {
1022+ f.order = append(f.order[:i], f.order[i+1:]...)
1023+ break
1024+ }
1025+ }
1026+ f.mu.Unlock()
1027+ return nil
1028+}
1029+
1030+// Snapshot returns a read-only view of one agent. Safe for the TUI
1031+// goroutine to call repeatedly; never blocks on the Runner.
1032+type AgentSnapshot struct {
1033+ ID string
1034+ Task string
1035+ Dir string
1036+ Status Status
1037+ ProcessState ProcessState
1038+ TurnState TurnState
1039+ CurrentTurnID string
1040+ Attempt int
1041+ Activity string
1042+ Started time.Time
1043+ Finished time.Time
1044+ CreatedAt time.Time
1045+ UpdatedAt time.Time
1046+ LastActivity time.Time
1047+ Err string
1048+ Tail string // last few transcript lines, joined with "\n"
1049+ Lines []string // full transcript (already capped by Agent.appendTranscript)
1050+ LastAssistant string // complete final assistant message, without role formatting
1051+ Result *TurnResult
1052+ ResultRef string
1053+ PatchRef string
1054+ ChangedFiles []string
1055+ OutputTruncated bool
1056+
1057+ // Model, Provider, Reasoning, and the provider connection settings
1058+ // expose the per-agent configuration captured at Spawn time. Empty
1059+ // values inherit the child's defaults. Subagent is the selected named
1060+ // markdown profile. The dashboard surfaces these so the user can
1061+ // confirm the child configuration.
1062+ Model string
1063+ Provider string
1064+ BaseURL string
1065+ InsecureTLS bool
1066+ Reasoning string
1067+ FastMode bool
1068+ Subagent string
1069+ WorkspaceMode WorkspaceMode
1070+ WorkspacePath string
1071+
1072+ // Paths to the agent's durable state. Surface them in the
1073+ // snapshot so the dashboard /subagents view can
1074+ // read events.jsonl or resume the session without going back through the Agent.
1075+ InboxPath string
1076+ EventLogPath string
1077+ SessionPath string
1078+}
1079+
1080+// Snapshot copies the live agent state into a value the caller can
1081+// inspect at leisure.
1082+func (a *Agent) Snapshot() AgentSnapshot {
1083+ a.mu.Lock()
1084+ tail := strings.Join(lastN(a.transcript, 6), "\n")
1085+ lines := make([]string, len(a.transcript))
1086+ copy(lines, a.transcript)
1087+ errStr := ""
1088+ if a.lastErr != nil {
1089+ errStr = a.lastErr.Error()
1090+ }
1091+ status := a.status
1092+ activity := a.activity
1093+ lastAssistant := a.lastAssistant
1094+ finished := a.finished
1095+ a.mu.Unlock()
1096+
1097+ a.lifecycleMu.Lock()
1098+ processState := a.processState
1099+ turnState := a.turnState
1100+ currentTurnID := a.currentTurnID
1101+ attempt := a.Attempt
1102+ lastActivity := a.lastActivity
1103+ updatedAt := a.updatedAt
1104+ result := cloneTurnResult(a.result)
1105+ resultRef := a.resultRef
1106+ patchRef := a.patchRef
1107+ changedFiles := append([]string(nil), a.changedFiles...)
1108+ outputTruncated := a.outputTruncated
1109+ a.lifecycleMu.Unlock()
1110+ return AgentSnapshot{
1111+ ID: a.ID, Task: a.Task, Dir: a.Dir,
1112+ Status: status, ProcessState: processState, TurnState: turnState,
1113+ CurrentTurnID: currentTurnID, Attempt: attempt, Activity: activity,
1114+ Started: a.Started, Finished: finished, CreatedAt: a.Started, UpdatedAt: updatedAt, LastActivity: lastActivity,
1115+ Err: errStr, Tail: tail, Lines: lines,
1116+ LastAssistant: lastAssistant, Result: result, ResultRef: resultRef,
1117+ PatchRef: patchRef, ChangedFiles: changedFiles, OutputTruncated: outputTruncated,
1118+ Model: a.Model, Provider: a.Provider, BaseURL: a.BaseURL,
1119+ InsecureTLS: a.InsecureTLS, Reasoning: a.Reasoning,
1120+ FastMode: a.FastMode, Subagent: a.Subagent, WorkspaceMode: a.WorkspaceMode,
1121+ WorkspacePath: a.WorkspacePath, InboxPath: a.InboxPath,
1122+ EventLogPath: a.EventLogPath, SessionPath: a.SessionPath,
1123+ }
1124+}
1125+
1126+// SnapshotAll returns snapshots of every agent in creation order,
1127+// scoped to the active session when one is set.
1128+//
1129+// Scoping rules:
1130+// - activeSession == "": no filter, every agent is returned
1131+// (historical behaviour; used by tests and scripted callers).
1132+// - activeSession != "": include live agents whose SessionID matches
1133+// activeSession, plus unscoped agents. Detached and terminal
1134+// agents from another host session remain visible so restart/recovery
1135+// does not hide durable work.
1136+func (f *Supervisor) SnapshotAll() []AgentSnapshot {
1137+ agents := f.List()
1138+ f.mu.Lock()
1139+ active := f.activeSession
1140+ f.mu.Unlock()
1141+
1142+ out := make([]AgentSnapshot, 0, len(agents))
1143+ for _, a := range agents {
1144+ if active != "" && a.SessionID != "" && a.SessionID != active {
1145+ // Keep detached/terminal entries visible after a normal host
1146+ // restart so durable recovery remains discoverable. Hide only
1147+ // live work belonging to another in-process host session.
1148+ status := a.Status()
1149+ if status == StatusRunning || status == StatusPending {
1150+ continue
1151+ }
1152+ }
1153+ out = append(out, a.Snapshot())
1154+ }
1155+ // Sort by start time for a stable, deterministic listing.
1156+ sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) })
1157+ return out
1158+}
1159+
1160+// agentSink is the Sink the Supervisor hands to each Runner.
1161+type agentSink struct{ a *Agent }
1162+
1163+func (s agentSink) Activity(msg string) { s.a.setActivity(msg) }
1164+func (s agentSink) Transcript(chunk string) {
1165+ if strings.HasPrefix(chunk, "stderr: ") || strings.HasPrefix(chunk, "error: ") {
1166+ s.a.appendTranscript(chunk)
1167+ return
1168+ }
1169+ s.a.appendAssistantMessage(chunk)
1170+}
1171+func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }
1172+func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }
1173+
1174+func truncate(s string, n int) string {
1175+ runes := []rune(s)
1176+ if len(runes) <= n {
1177+ return s
1178+ }
1179+ if n <= 3 {
1180+ return strings.Repeat(".", n)
1181+ }
1182+ return string(runes[:n-3]) + "..."
1183+}
1184+
1185+func lastN(lines []string, n int) []string {
1186+ if len(lines) <= n {
1187+ return lines
1188+ }
1189+ return lines[len(lines)-n:]
1190+}
R packages/agent/swarm/swarm_test.go → packages/agent/subagents/supervisor_test.go +155 -17 renamed, 60% similar
@@ -1,19 +1,19 @@
1-package swarm
1+package subagents
22
33 import (
44 "context"
55 "errors"
6+ "path/filepath"
7+ "runtime"
68 "strings"
79 "sync"
810 "testing"
911 "time"
1012 )
1113
12-// newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory
13-// and a Runner factory the test controls. Returns the swarm plus a
14-// slice of runners keyed by spawn order so tests can assert they
15-// were actually invoked.
16-func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm {
14+// newTestSupervisor builds a Supervisor rooted in t.TempDir and configured
15+// with the Runner factory controlled by the test.
16+func newTestSupervisor(t *testing.T, mk func(a *Agent) Runner) *Supervisor {
1717 t.Helper()
1818 root := t.TempDir()
1919 return New(Config{
@@ -25,7 +25,7 @@ func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm {
2525
2626 func TestSpawnRunsAndCompletes(t *testing.T) {
2727 ran := make(chan string, 1)
28- f := newTestSwarm(t, func(a *Agent) Runner {
28+ f := newTestSupervisor(t, func(a *Agent) Runner {
2929 return RunnerFunc(func(ctx context.Context, sink Sink) error {
3030 ran <- a.Task
3131 sink.Activity("hello")
@@ -65,7 +65,7 @@ func TestSpawnRunsAndCompletes(t *testing.T) {
6565 // TestSpawnAgentSharesRepoRoot verifies the only-mode-we-support:
6666 // every spawned agent points its cwd at the parent zot's RepoRoot.
6767 func TestSpawnAgentSharesRepoRoot(t *testing.T) {
68- f := newTestSwarm(t, func(a *Agent) Runner {
68+ f := newTestSupervisor(t, func(a *Agent) Runner {
6969 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
7070 })
7171 a, err := f.Spawn(context.Background(), "share me")
@@ -79,7 +79,7 @@ func TestSpawnAgentSharesRepoRoot(t *testing.T) {
7979 }
8080
8181 func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) {
82- f := newTestSwarm(t, func(a *Agent) Runner {
82+ f := newTestSupervisor(t, func(a *Agent) Runner {
8383 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
8484 })
8585 want := t.TempDir()
@@ -95,7 +95,7 @@ func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) {
9595 }
9696
9797 func TestSpawnEmptyTaskFails(t *testing.T) {
98- f := newTestSwarm(t, func(a *Agent) Runner {
98+ f := newTestSupervisor(t, func(a *Agent) Runner {
9999 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
100100 })
101101 if _, err := f.Spawn(context.Background(), " "); err == nil {
@@ -105,7 +105,7 @@ func TestSpawnEmptyTaskFails(t *testing.T) {
105105
106106 func TestRunnerErrorMarksFailed(t *testing.T) {
107107 wantErr := errors.New("boom")
108- f := newTestSwarm(t, func(a *Agent) Runner {
108+ f := newTestSupervisor(t, func(a *Agent) Runner {
109109 return RunnerFunc(func(ctx context.Context, sink Sink) error { return wantErr })
110110 })
111111 a, _ := f.Spawn(context.Background(), "explode")
@@ -123,7 +123,7 @@ func TestRunnerErrorMarksFailed(t *testing.T) {
123123
124124 func TestStopCancelsRunningAgent(t *testing.T) {
125125 started := make(chan struct{})
126- f := newTestSwarm(t, func(a *Agent) Runner {
126+ f := newTestSupervisor(t, func(a *Agent) Runner {
127127 return RunnerFunc(func(ctx context.Context, sink Sink) error {
128128 close(started)
129129 <-ctx.Done()
@@ -141,8 +141,133 @@ func TestStopCancelsRunningAgent(t *testing.T) {
141141 }
142142 }
143143
144+func TestStopContextCallerCancellationDoesNotEndGracePeriod(t *testing.T) {
145+ if runtime.GOOS == "windows" {
146+ t.Skip("subagent inbox transport uses Unix-domain sockets")
147+ }
148+ const grace = 250 * time.Millisecond
149+ started := make(chan struct{})
150+ canceled := make(chan struct{})
151+ f := New(Config{
152+ Root: t.TempDir(),
153+ RepoRoot: t.TempDir(),
154+ Policy: SubagentPolicy{
155+ CancelGracePeriod: grace,
156+ },
157+ NewRunner: func(a *Agent) Runner {
158+ return RunnerFunc(func(ctx context.Context, sink Sink) error {
159+ listener, err := Listen(a.InboxPath)
160+ if err != nil {
161+ return err
162+ }
163+ defer listener.Close()
164+ close(started)
165+ select {
166+ case <-listener.Lines():
167+ case <-ctx.Done():
168+ close(canceled)
169+ return ctx.Err()
170+ }
171+ <-ctx.Done()
172+ close(canceled)
173+ return ctx.Err()
174+ })
175+ },
176+ })
177+ a, err := f.Spawn(context.Background(), "graceful stop")
178+ if err != nil {
179+ t.Fatal(err)
180+ }
181+ select {
182+ case <-started:
183+ case <-time.After(time.Second):
184+ t.Fatal("runner did not start")
185+ }
186+
187+ ctx, cancel := context.WithCancel(context.Background())
188+ if err := f.StopContext(ctx, a.ID); err != nil {
189+ t.Fatal(err)
190+ }
191+ cancel()
192+
193+ early := time.NewTimer(grace / 2)
194+ defer early.Stop()
195+ select {
196+ case <-canceled:
197+ t.Fatal("agent canceled when StopContext caller context was canceled")
198+ case <-early.C:
199+ }
200+
201+ deadline := time.NewTimer(grace * 2)
202+ defer deadline.Stop()
203+ select {
204+ case <-canceled:
205+ case <-deadline.C:
206+ t.Fatal("agent was not canceled after the grace period")
207+ }
208+ a.Wait()
209+}
210+
211+func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) {
212+ if runtime.GOOS == "windows" {
213+ t.Skip("subagent inbox transport uses Unix-domain sockets")
214+ }
215+ path := filepath.Join(shortSocketDir(t), "agent.sock")
216+ listener, err := Listen(path)
217+ if err != nil {
218+ t.Fatal(err)
219+ }
220+ defer listener.Close()
221+
222+ f := New(Config{
223+ Root: t.TempDir(),
224+ Policy: SubagentPolicy{
225+ CancelGracePeriod: time.Hour,
226+ },
227+ })
228+ a := &Agent{
229+ ID: "detached-agent",
230+ InboxPath: path,
231+ inbox: NewInbox(path),
232+ status: StatusDetached,
233+ done: make(chan struct{}),
234+ }
235+ defer a.inbox.Close()
236+ close(a.done)
237+ f.mu.Lock()
238+ f.agents[a.ID] = a
239+ f.order = append(f.order, a.ID)
240+ f.mu.Unlock()
241+
242+ ctx, cancel := context.WithCancel(context.Background())
243+ defer cancel()
244+ stopDone := make(chan error, 1)
245+ go func() { stopDone <- f.StopContext(ctx, a.ID) }()
246+
247+ // The listener notification proves Stop reached its shutdown wait. The
248+ // operation lock must already be available while that wait is in progress.
249+ if msg := <-listener.Lines(); msg == "" {
250+ t.Fatal("shutdown command was empty")
251+ }
252+ operationReleased := make(chan struct{})
253+ go func() {
254+ f.operationMu.Lock()
255+ close(operationReleased)
256+ f.operationMu.Unlock()
257+ }()
258+ <-operationReleased
259+
260+ cancel()
261+ if err := <-stopDone; !errors.Is(err, context.Canceled) {
262+ t.Fatalf("StopContext error = %v; want context.Canceled", err)
263+ }
264+ if got := a.Status(); got != StatusDetached {
265+ t.Fatalf("status after canceled stop = %s; want detached", got)
266+ }
267+}
268+
144269 func TestStopAfterDoneIsNoop(t *testing.T) {
145- f := newTestSwarm(t, func(a *Agent) Runner {
270+ f := newTestSupervisor(t, func(a *Agent) Runner {
146271 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
147272 })
148273 a, _ := f.Spawn(context.Background(), "quick")
@@ -156,7 +281,7 @@ func TestStopAfterDoneIsNoop(t *testing.T) {
156281 }
157282
158283 func TestGetPrefixMatch(t *testing.T) {
159- f := newTestSwarm(t, func(a *Agent) Runner {
284+ f := newTestSupervisor(t, func(a *Agent) Runner {
160285 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
161286 })
162287 a, _ := f.Spawn(context.Background(), "alpha task")
@@ -177,7 +302,7 @@ func TestGetPrefixMatch(t *testing.T) {
177302
178303 func TestRemoveRequiresTerminalState(t *testing.T) {
179304 hold := make(chan struct{})
180- f := newTestSwarm(t, func(a *Agent) Runner {
305+ f := newTestSupervisor(t, func(a *Agent) Runner {
181306 return RunnerFunc(func(ctx context.Context, sink Sink) error {
182307 <-hold
183308 return nil
@@ -204,7 +329,7 @@ func TestRemoveRequiresTerminalState(t *testing.T) {
204329 func TestSnapshotIsStableAcrossAccess(t *testing.T) {
205330 var wg sync.WaitGroup
206331 wg.Add(1)
207- f := newTestSwarm(t, func(a *Agent) Runner {
332+ f := newTestSupervisor(t, func(a *Agent) Runner {
208333 return RunnerFunc(func(ctx context.Context, sink Sink) error {
209334 defer wg.Done()
210335 for i := 0; i < 200; i++ {
@@ -236,6 +361,19 @@ func TestSnapshotIsStableAcrossAccess(t *testing.T) {
236361 }
237362 }
238363
364+func TestTruncateIsRuneAware(t *testing.T) {
365+ input := "αβγδεζη"
366+ if got := truncate(input, 6); got != "αβγ..." {
367+ t.Fatalf("truncate(%q, 6) = %q; want %q", input, got, "αβγ...")
368+ }
369+ if got := truncate(input, 3); got != "..." {
370+ t.Fatalf("truncate(%q, 3) = %q; want %q", input, got, "...")
371+ }
372+ if got := truncate(input, 2); got != ".." {
373+ t.Fatalf("truncate(%q, 2) = %q; want %q", input, got, "..")
374+ }
375+}
376+
239377 func TestTaskSlug(t *testing.T) {
240378 cases := map[string]string{
241379 "fix the login form": "fix-the-login-form",
@@ -251,7 +389,7 @@ func TestTaskSlug(t *testing.T) {
251389 }
252390
253391 func TestSnapshotAllSorted(t *testing.T) {
254- f := newTestSwarm(t, func(a *Agent) Runner {
392+ f := newTestSupervisor(t, func(a *Agent) Runner {
255393 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
256394 })
257395 a1, _ := f.Spawn(context.Background(), "first")
R packages/agent/swarm/testdata/cmd/stubchild/main.go → packages/agent/subagents/testdata/cmd/stubchild/main.go +31 -16 renamed, 72% similar
@@ -1,8 +1,8 @@
1-// stubchild is a minimal swarm-agent stand-in used by the runner
1+// stubchild is a minimal subagent-worker stand-in used by the runner
22 // end-to-end test. It speaks the daemon protocol the real zot
33 // binary will implement next:
44 //
5-// - parses --swarm-agent <path>, --session <path>, --cwd <path>,
5+// - parses --subagent-worker <path>, --session <path>, --cwd <path>,
66 // and an optional positional task,
77 // - opens a unix-socket listener at the inbox path so the
88 // supervisor's Inbox can dial through,
@@ -13,7 +13,7 @@
1313 // so the runner sees the dialogue happen.
1414 //
1515 // The runner test compiles this binary into a tempdir, points
16-// swarm's execRunner at it via Command, and asserts the events
16+// subagent's execRunner at it via Command, and asserts the events
1717 // flow through correctly without needing the full zot model
1818 // machinery.
1919 package main
@@ -27,6 +27,8 @@ import (
2727 "os"
2828 "sync"
2929 "time"
30+
31+ "github.com/patriceckhart/zot/packages/agent/subagents"
3032 )
3133
3234 func main() {
@@ -35,13 +37,13 @@ func main() {
3537 sessionPath string
3638 cwd string
3739 )
38- flag.StringVar(&inboxPath, "swarm-agent", "", "inbox socket path")
40+ flag.StringVar(&inboxPath, "subagent-worker", "", "inbox socket path")
3941 flag.StringVar(&sessionPath, "session", "", "session file path")
4042 flag.StringVar(&cwd, "cwd", "", "working directory")
4143 flag.Parse()
4244
4345 if inboxPath == "" {
44- fmt.Fprintln(os.Stderr, "stubchild: --swarm-agent required")
46+ fmt.Fprintln(os.Stderr, "stubchild: --subagent-worker required")
4547 os.Exit(2)
4648 }
4749
@@ -49,7 +51,7 @@ func main() {
4951
5052 // Open the inbox listener BEFORE doing any work so the supervisor
5153 // 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
54+ // processed the initial task first, the parent's send call
5355 // would race the stub's net.Listen and trip ErrNotReady.
5456 ln, err := net.Listen("unix", inboxPath)
5557 if err != nil {
@@ -81,18 +83,25 @@ func main() {
8183 line, err := br.ReadString('\n')
8284 if line != "" {
8385 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++
86+ command, parseErr := subagents.ParseCommand(msg)
87+ if parseErr == nil {
88+ switch command.Type {
89+ case subagents.CommandAgentShutdown:
90+ emit("agent_stopped", map[string]any{"reason": "shutdown"})
91+ _ = c.Close()
92+ return
93+ case subagents.CommandTurnCancel:
94+ emit("turn_end", map[string]any{"stop": "cancelled"})
95+ case subagents.CommandTurnStart:
96+ var payload subagents.TurnStartPayload
97+ if command.DecodePayload(&payload) == nil {
98+ runTurn(emit, payload.Prompt, turn)
99+ turn++
100+ }
101+ }
94102 }
95103 }
104+
96105 if err != nil {
97106 _ = c.Close()
98107 break
@@ -125,12 +134,18 @@ type emitter = func(string, map[string]any)
125134 func newEmitter() emitter {
126135 var mu sync.Mutex
127136 enc := json.NewEncoder(os.Stdout)
137+ versioned := os.Getenv("ZOT_STUB_PROTOCOL") == "1"
128138 return func(typ string, data map[string]any) {
129139 mu.Lock()
130140 defer mu.Unlock()
131141 if data == nil {
132142 data = map[string]any{}
133143 }
144+ if versioned {
145+ envelope := subagents.NewEventEnvelope(typ, "stub-agent", "", data)
146+ _ = enc.Encode(envelope)
147+ return
148+ }
134149 data["type"] = typ
135150 data["time"] = time.Now().Format(time.RFC3339Nano)
136151 _ = enc.Encode(data)
A packages/agent/subagents/workspace.go +384 -0
@@ -0,0 +1,384 @@
1+package subagents
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "strings"
12+)
13+
14+// WorkspaceCapture is durable output collected before an isolated workspace
15+// is removed. The patch is intentionally kept as bytes so the caller can
16+// write it atomically beside the agent result.
17+type WorkspaceCapture struct {
18+ Patch []byte
19+ ChangedFiles []string
20+ Base string
21+ Branch string
22+}
23+
24+// WorkspaceRequest describes one workspace allocation.
25+type WorkspaceRequest struct {
26+ Mode WorkspaceMode
27+ RepositoryRoot string
28+ StateDir string
29+ AgentID string
30+ Base string
31+ Capture CaptureMode
32+ AllowedRoots []string
33+
34+ // ExistingPath is set only when resuming a detached worktree. A valid
35+ // path is reused so uncommitted edits survive a supervisor restart;
36+ // invalid or out-of-scope paths are rejected rather than treated as a
37+ // request to remove arbitrary files.
38+ ExistingPath string
39+}
40+
41+// WorkspaceHandle is the narrow seam used by the manager. SharedWorkspace
42+// and GitWorktreeWorkspace are the two supported implementations in v1.
43+type WorkspaceHandle interface {
44+ Dir() string
45+ RepositoryRoot() string
46+ Mode() WorkspaceMode
47+ Capture(context.Context) (WorkspaceCapture, error)
48+ Cleanup(context.Context) error
49+}
50+
51+// SharedWorkspace leaves the host checkout untouched by the supervisor; a
52+// child may edit it exactly as older subagent workers did.
53+type SharedWorkspace struct {
54+ Root string
55+}
56+
57+func (w SharedWorkspace) Prepare(context.Context, WorkspaceRequest) (WorkspaceHandle, error) {
58+ root := w.Root
59+ if root == "" {
60+ return nil, errors.New("subagents: shared workspace root is empty")
61+ }
62+ return &sharedWorkspaceHandle{root: root}, nil
63+}
64+
65+type sharedWorkspaceHandle struct{ root string }
66+
67+func (w *sharedWorkspaceHandle) Dir() string { return w.root }
68+func (w *sharedWorkspaceHandle) RepositoryRoot() string { return w.root }
69+func (w *sharedWorkspaceHandle) Mode() WorkspaceMode { return WorkspaceShared }
70+func (w *sharedWorkspaceHandle) Capture(context.Context) (WorkspaceCapture, error) {
71+ return WorkspaceCapture{}, nil
72+}
73+func (w *sharedWorkspaceHandle) Cleanup(context.Context) error { return nil }
74+
75+// GitWorktreeWorkspace creates a detached temporary worktree from Base and
76+// captures its diff before cleanup. It never merges or changes the host
77+// checkout's branch.
78+type GitWorktreeWorkspace struct{}
79+
80+func (GitWorktreeWorkspace) Prepare(ctx context.Context, req WorkspaceRequest) (WorkspaceHandle, error) {
81+ if ctx == nil {
82+ ctx = context.Background()
83+ }
84+ root, err := filepath.Abs(req.RepositoryRoot)
85+ if err != nil {
86+ return nil, fmt.Errorf("subagents worktree root: %w", err)
87+ }
88+ if _, err := os.Stat(root); err != nil {
89+ return nil, fmt.Errorf("subagents worktree root: %w", err)
90+ }
91+ actualRoot, err := gitOutput(ctx, root, "rev-parse", "--show-toplevel")
92+ if err != nil {
93+ return nil, fmt.Errorf("subagents worktree verify repository: %w", err)
94+ }
95+ actualRoot = filepath.Clean(actualRoot)
96+ if len(req.AllowedRoots) > 0 {
97+ allowed := false
98+ for _, root := range req.AllowedRoots {
99+ if pathWithin(actualRoot, root) {
100+ allowed = true
101+ break
102+ }
103+ }
104+ if !allowed {
105+ return nil, errors.New("subagents worktree repository is outside allowed roots")
106+ }
107+ }
108+ base := strings.TrimSpace(req.Base)
109+ if base == "" {
110+ base = "HEAD"
111+ }
112+ if _, err := gitOutput(ctx, actualRoot, "rev-parse", "--verify", base+"^{commit}"); err != nil {
113+ return nil, fmt.Errorf("subagents worktree verify base %q: %w", base, err)
114+ }
115+ stateDir := req.StateDir
116+ if stateDir == "" {
117+ return nil, errors.New("subagents: worktree state directory is empty")
118+ }
119+ worktreeDir := filepath.Join(stateDir, "worktree")
120+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
121+ return nil, fmt.Errorf("subagents worktree state dir: %w", err)
122+ }
123+ reuse := false
124+ if existing := strings.TrimSpace(req.ExistingPath); existing != "" {
125+ var err error
126+ reuse, err = validateReusableWorktree(ctx, actualRoot, stateDir, worktreeDir, existing)
127+ if err != nil {
128+ return nil, err
129+ }
130+ if reuse {
131+ worktreeDir = filepath.Clean(existing)
132+ }
133+ }
134+ if !reuse {
135+ _ = os.RemoveAll(worktreeDir)
136+ _, _ = gitOutput(ctx, actualRoot, "worktree", "prune")
137+ if _, err := gitOutput(ctx, actualRoot, "worktree", "add", "--detach", worktreeDir, base); err != nil {
138+ _ = os.RemoveAll(worktreeDir)
139+ _, _ = gitOutput(ctx, actualRoot, "worktree", "prune")
140+ return nil, fmt.Errorf("subagents worktree create: %w", err)
141+ }
142+ }
143+ return &gitWorktreeHandle{
144+ root: actualRoot,
145+ dir: worktreeDir,
146+ base: base,
147+ capture: req.Capture,
148+ agentID: req.AgentID,
149+ }, nil
150+}
151+
152+func validateReusableWorktree(ctx context.Context, repositoryRoot, stateDir, expectedPath, existingPath string) (bool, error) {
153+ if filepath.Clean(existingPath) != filepath.Clean(expectedPath) {
154+ return false, errors.New("subagents worktree resume path is outside the agent state directory")
155+ }
156+ info, err := os.Stat(existingPath)
157+ if errors.Is(err, os.ErrNotExist) {
158+ return false, nil
159+ }
160+ if err != nil {
161+ return false, fmt.Errorf("subagents worktree resume path: %w", err)
162+ }
163+ if !info.IsDir() {
164+ return false, errors.New("subagents worktree resume path is not a directory")
165+ }
166+ stateCanonical, err := containmentPath(stateDir)
167+ if err != nil {
168+ return false, fmt.Errorf("subagents worktree resume state path: %w", err)
169+ }
170+ expectedCanonical, err := containmentPath(expectedPath)
171+ if err != nil {
172+ return false, fmt.Errorf("subagents worktree resume expected path: %w", err)
173+ }
174+ existingCanonical, err := containmentPath(existingPath)
175+ if err != nil {
176+ return false, fmt.Errorf("subagents worktree resume path: %w", err)
177+ }
178+ if existingCanonical != expectedCanonical || !pathWithin(existingCanonical, stateCanonical) {
179+ return false, errors.New("subagents worktree resume path escapes the agent state directory")
180+ }
181+ top, err := gitOutput(ctx, existingPath, "rev-parse", "--show-toplevel")
182+ if err != nil {
183+ return false, fmt.Errorf("subagents worktree resume verify checkout: %w", err)
184+ }
185+ topCanonical, err := containmentPath(top)
186+ if err != nil {
187+ return false, fmt.Errorf("subagents worktree resume checkout path: %w", err)
188+ }
189+ if topCanonical != existingCanonical {
190+ return false, errors.New("subagents worktree resume checkout path mismatch")
191+ }
192+ registered, err := gitOutput(ctx, repositoryRoot, "worktree", "list", "--porcelain")
193+ if err != nil {
194+ return false, fmt.Errorf("subagents worktree resume list: %w", err)
195+ }
196+ for _, line := range strings.Split(registered, "\n") {
197+ if !strings.HasPrefix(line, "worktree ") {
198+ continue
199+ }
200+ registeredPath, pathErr := containmentPath(strings.TrimPrefix(line, "worktree "))
201+ if pathErr == nil && registeredPath == existingCanonical {
202+ return true, nil
203+ }
204+ }
205+ return false, errors.New("subagents worktree resume checkout is not registered with Git")
206+}
207+
208+type gitWorktreeHandle struct {
209+ root string
210+ dir string
211+ base string
212+ capture CaptureMode
213+ agentID string
214+}
215+
216+func (w *gitWorktreeHandle) Dir() string { return w.dir }
217+func (w *gitWorktreeHandle) RepositoryRoot() string { return w.root }
218+func (w *gitWorktreeHandle) Mode() WorkspaceMode { return WorkspaceWorktree }
219+
220+func (w *gitWorktreeHandle) Capture(ctx context.Context) (WorkspaceCapture, error) {
221+ if ctx == nil {
222+ ctx = context.Background()
223+ }
224+ statusBytes, err := gitOutputBytes(ctx, w.dir, "status", "--porcelain", "-z")
225+ if err != nil {
226+ return WorkspaceCapture{}, fmt.Errorf("subagents worktree status: %w", err)
227+ }
228+ changed := changedFilesFromStatus(statusBytes)
229+ args := []string{"diff"}
230+ if w.capture == CapturePatch || w.capture == "" {
231+ args = append(args, "--binary")
232+ }
233+ args = append(args, w.base, "--")
234+ patch, err := gitOutputBytes(ctx, w.dir, args...)
235+ if err != nil {
236+ return WorkspaceCapture{}, fmt.Errorf("subagents worktree diff: %w", err)
237+ }
238+
239+ // `git diff <base>` does not include untracked files. Add a normal
240+ // no-index creation patch for each untracked file so a worktree result can
241+ // reproduce new files as well as edits to tracked files.
242+ untracked, err := gitOutputBytes(ctx, w.dir, "ls-files", "--others", "--exclude-standard", "-z")
243+ if err != nil {
244+ return WorkspaceCapture{}, fmt.Errorf("subagents worktree untracked files: %w", err)
245+ }
246+ for _, name := range splitNUL(untracked) {
247+ extra, diffErr := gitUntrackedDiff(ctx, w.dir, name, w.capture == CapturePatch || w.capture == "")
248+ if diffErr != nil {
249+ return WorkspaceCapture{}, fmt.Errorf("subagents worktree untracked diff %q: %w", name, diffErr)
250+ }
251+ if len(extra) == 0 {
252+ continue
253+ }
254+ if len(patch) > 0 && patch[len(patch)-1] != '\n' {
255+ patch = append(patch, '\n')
256+ }
257+ patch = append(patch, extra...)
258+ }
259+ return WorkspaceCapture{Patch: patch, ChangedFiles: changed, Base: w.base}, nil
260+}
261+
262+func (w *gitWorktreeHandle) Cleanup(ctx context.Context) error {
263+ if ctx == nil {
264+ ctx = context.Background()
265+ }
266+ if w.dir == "" {
267+ return nil
268+ }
269+ _, err := gitOutput(ctx, w.root, "worktree", "remove", "--force", w.dir)
270+ if err != nil {
271+ // A failed child can leave a partially registered worktree. Remove
272+ // only the isolated path; never delete files in the host checkout,
273+ // then prune stale registration metadata.
274+ _ = os.RemoveAll(w.dir)
275+ _, _ = gitOutput(ctx, w.root, "worktree", "prune")
276+ return fmt.Errorf("subagents worktree cleanup: %w", err)
277+ }
278+ return nil
279+}
280+
281+func PrepareWorkspace(ctx context.Context, req WorkspaceRequest) (WorkspaceHandle, error) {
282+ mode := req.Mode
283+ if mode == "" {
284+ mode = WorkspaceShared
285+ }
286+ switch mode {
287+ case WorkspaceShared:
288+ return SharedWorkspace{Root: req.RepositoryRoot}.Prepare(ctx, req)
289+ case WorkspaceWorktree:
290+ return GitWorktreeWorkspace{}.Prepare(ctx, req)
291+ default:
292+ return nil, fmt.Errorf("subagents: unknown workspace mode %q", mode)
293+ }
294+}
295+
296+func gitOutput(ctx context.Context, dir string, args ...string) (string, error) {
297+ b, err := gitOutputBytes(ctx, dir, args...)
298+ return strings.TrimSpace(string(b)), err
299+}
300+
301+func gitOutputBytes(ctx context.Context, dir string, args ...string) ([]byte, error) {
302+ cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
303+ var stdout, stderr bytes.Buffer
304+ cmd.Stdout = &stdout
305+ cmd.Stderr = &stderr
306+ if err := cmd.Run(); err != nil {
307+ message := strings.TrimSpace(stderr.String())
308+ if message == "" {
309+ message = err.Error()
310+ }
311+ return nil, errors.New(message)
312+ }
313+ return stdout.Bytes(), nil
314+}
315+
316+func gitUntrackedDiff(ctx context.Context, dir, name string, binary bool) ([]byte, error) {
317+ args := []string{"-C", dir, "diff", "--no-index"}
318+ if binary {
319+ args = append(args, "--binary")
320+ }
321+ args = append(args, os.DevNull, "--", name)
322+ cmd := exec.CommandContext(ctx, "git", args...)
323+ var stdout, stderr bytes.Buffer
324+ cmd.Stdout = &stdout
325+ cmd.Stderr = &stderr
326+ err := cmd.Run()
327+ if err == nil {
328+ return stdout.Bytes(), nil
329+ }
330+ var exitErr *exec.ExitError
331+ if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
332+ return stdout.Bytes(), nil
333+ }
334+ message := strings.TrimSpace(stderr.String())
335+ if message == "" {
336+ message = err.Error()
337+ }
338+ return nil, errors.New(message)
339+}
340+
341+func splitNUL(data []byte) []string {
342+ parts := strings.Split(string(data), "\x00")
343+ out := make([]string, 0, len(parts))
344+ for _, part := range parts {
345+ if part != "" {
346+ out = append(out, part)
347+ }
348+ }
349+ return out
350+}
351+
352+func changedFilesFromStatus(status []byte) []string {
353+ var files []string
354+ for i := 0; i < len(status); {
355+ end := bytes.IndexByte(status[i:], 0)
356+ if end < 0 {
357+ end = len(status) - i
358+ }
359+ record := status[i : i+end]
360+ i += end + 1
361+ if len(record) < 4 {
362+ continue
363+ }
364+ // With `git status --porcelain -z`, rename and copy records contain
365+ // the new path in the status record, followed by the old path as the
366+ // next NUL-delimited field.
367+ path := record[3:]
368+ if len(path) == 0 {
369+ continue
370+ }
371+ files = append(files, string(path))
372+ isRename := record[0] == 'R' || record[0] == 'C' || record[1] == 'R' || record[1] == 'C'
373+ if isRename {
374+ if i < len(status) {
375+ if end := bytes.IndexByte(status[i:], 0); end >= 0 {
376+ i += end + 1
377+ } else {
378+ i = len(status)
379+ }
380+ }
381+ }
382+ }
383+ return files
384+}
A packages/agent/subagents/workspace_manager_test.go +65 -0
@@ -0,0 +1,65 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestSpawnWorktreeCapturesBeforeCleanup(t *testing.T) {
13+ if _, err := exec.LookPath("git"); err != nil {
14+ t.Skip("git is required")
15+ }
16+ repo := initTestRepo(t)
17+ if err := os.WriteFile(filepath.Join(repo, "main.txt"), []byte("host\n"), 0o600); err != nil {
18+ t.Fatal(err)
19+ }
20+ runGit(t, repo, "add", "main.txt")
21+ runGit(t, repo, "commit", "-m", "initial")
22+
23+ state := t.TempDir()
24+ f := New(Config{
25+ Root: state, RepoRoot: repo,
26+ Policy: SubagentPolicy{DefaultTimeout: 0, IdleTimeout: 0},
27+ NewRunner: func(a *Agent) Runner {
28+ return RunnerFunc(func(context.Context, Sink) error {
29+ if err := os.WriteFile(filepath.Join(a.Dir, "main.txt"), []byte("child\n"), 0o600); err != nil {
30+ return err
31+ }
32+ return os.WriteFile(filepath.Join(a.Dir, "new.txt"), []byte("created\n"), 0o600)
33+ })
34+ },
35+ })
36+ a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "isolate", WorkspaceMode: WorkspaceWorktree, WorkspaceBase: "HEAD"})
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ a.Wait()
41+ result, err := f.ReadResult(a.ID)
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ if result.Status != ResultSucceeded || len(result.ChangedFiles) != 2 || result.ChangedFiles[0] != "main.txt" || result.ChangedFiles[1] != "new.txt" {
46+ t.Fatalf("result = %#v", result)
47+ }
48+ patch, err := os.ReadFile(filepath.Join(state, "agents", a.ID, "patch.diff"))
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ if !strings.Contains(string(patch), "child") || !strings.Contains(string(patch), "created") {
53+ t.Fatalf("patch = %q", patch)
54+ }
55+ host, err := os.ReadFile(filepath.Join(repo, "main.txt"))
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if string(host) != "host\n" {
60+ t.Fatalf("host checkout changed: %q", host)
61+ }
62+ if _, err := os.Stat(a.WorkspacePath); !os.IsNotExist(err) {
63+ t.Fatalf("worktree still exists: %v", err)
64+ }
65+}
A packages/agent/subagents/workspace_test.go +192 -0
@@ -0,0 +1,192 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestGitWorktreeWorkspaceIsolatesAndCapturesPatch(t *testing.T) {
13+ if _, err := exec.LookPath("git"); err != nil {
14+ t.Skip("git is required")
15+ }
16+ repo := initTestRepo(t)
17+
18+ state := t.TempDir()
19+ handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{
20+ Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, AgentID: "worker-1", Base: "HEAD", Capture: CapturePatch,
21+ })
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ if handle.Mode() != WorkspaceWorktree || handle.Dir() == repo {
26+ t.Fatalf("handle = mode %s dir %q repo %q", handle.Mode(), handle.Dir(), repo)
27+ }
28+ if err := os.WriteFile(filepath.Join(handle.Dir(), "README.md"), []byte("after\n"), 0o600); err != nil {
29+ t.Fatal(err)
30+ }
31+ before, err := os.ReadFile(filepath.Join(repo, "README.md"))
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if string(before) != "before\n" {
36+ t.Fatalf("host checkout changed: %q", before)
37+ }
38+ capture, err := handle.Capture(context.Background())
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if len(capture.Patch) == 0 || !strings.Contains(string(capture.Patch), "after") {
43+ t.Fatalf("patch = %q", capture.Patch)
44+ }
45+ if len(capture.ChangedFiles) != 1 || capture.ChangedFiles[0] != "README.md" {
46+ t.Fatalf("changed files = %#v", capture.ChangedFiles)
47+ }
48+ if err := handle.Cleanup(context.Background()); err != nil {
49+ t.Fatal(err)
50+ }
51+ if _, err := os.Stat(handle.Dir()); !os.IsNotExist(err) {
52+ t.Fatalf("worktree still exists: %v", err)
53+ }
54+}
55+
56+func TestGitWorktreeWorkspaceReusesDetachedCheckout(t *testing.T) {
57+ if _, err := exec.LookPath("git"); err != nil {
58+ t.Skip("git is required")
59+ }
60+ repo := initTestRepo(t)
61+
62+ state := t.TempDir()
63+ first, err := PrepareWorkspace(context.Background(), WorkspaceRequest{
64+ Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, AgentID: "worker-1", Base: "HEAD", Capture: CapturePatch,
65+ })
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ if err := os.WriteFile(filepath.Join(first.Dir(), "draft.txt"), []byte("keep me\n"), 0o600); err != nil {
70+ t.Fatal(err)
71+ }
72+ resumed, err := PrepareWorkspace(context.Background(), WorkspaceRequest{
73+ Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, AgentID: "worker-1", Base: "HEAD", Capture: CapturePatch,
74+ ExistingPath: first.Dir(),
75+ })
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ if resumed.Dir() != first.Dir() {
80+ t.Fatalf("resume dir = %q, want %q", resumed.Dir(), first.Dir())
81+ }
82+ if _, err := os.Stat(filepath.Join(resumed.Dir(), "draft.txt")); err != nil {
83+ t.Fatalf("resumed worktree lost uncommitted file: %v", err)
84+ }
85+ if err := resumed.Cleanup(context.Background()); err != nil {
86+ t.Fatal(err)
87+ }
88+}
89+
90+func TestGitWorktreeWorkspaceCaptureIncludesUntrackedFile(t *testing.T) {
91+ if _, err := exec.LookPath("git"); err != nil {
92+ t.Skip("git is required")
93+ }
94+ repo := initTestRepo(t)
95+ state := t.TempDir()
96+ handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{
97+ Mode: WorkspaceWorktree, RepositoryRoot: repo, StateDir: state, Base: "HEAD", Capture: CapturePatch,
98+ })
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ defer func() { _ = handle.Cleanup(context.Background()) }()
103+
104+ name := "new file – 日本語.txt"
105+ if err := os.WriteFile(filepath.Join(handle.Dir(), name), []byte("created\n"), 0o600); err != nil {
106+ t.Fatal(err)
107+ }
108+ capture, err := handle.Capture(context.Background())
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ if len(capture.ChangedFiles) != 1 || capture.ChangedFiles[0] != name {
113+ t.Fatalf("changed files = %#v, want [%q]", capture.ChangedFiles, name)
114+ }
115+ if !strings.Contains(string(capture.Patch), "created") {
116+ t.Fatalf("patch = %q", capture.Patch)
117+ }
118+}
119+
120+func TestChangedFilesFromStatus(t *testing.T) {
121+ status := []byte(" M path with spaces.txt\x00R renamed – 新.txt\x00original name – старый.txt\x00?? untracked – 文件.txt\x00")
122+ got := changedFilesFromStatus(status)
123+ want := []string{"path with spaces.txt", "renamed – 新.txt", "untracked – 文件.txt"}
124+ if len(got) != len(want) {
125+ t.Fatalf("changed files = %#v, want %#v", got, want)
126+ }
127+ for i := range want {
128+ if got[i] != want[i] {
129+ t.Errorf("changed files[%d] = %q, want %q", i, got[i], want[i])
130+ }
131+ }
132+}
133+
134+func TestSharedWorkspaceUsesRepositoryRoot(t *testing.T) {
135+ root := t.TempDir()
136+ handle, err := PrepareWorkspace(context.Background(), WorkspaceRequest{Mode: WorkspaceShared, RepositoryRoot: root})
137+ if err != nil {
138+ t.Fatal(err)
139+ }
140+ if handle.Dir() != root || handle.RepositoryRoot() != root || handle.Mode() != WorkspaceShared {
141+ t.Fatalf("shared handle = %#v", handle)
142+ }
143+ if err := handle.Cleanup(context.Background()); err != nil {
144+ t.Fatal(err)
145+ }
146+}
147+
148+func initTestRepo(t *testing.T) string {
149+ t.Helper()
150+ home := t.TempDir()
151+ t.Setenv("HOME", home)
152+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config"))
153+ t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
154+ t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
155+ unsetenvForTest(t, "GIT_DIR")
156+ unsetenvForTest(t, "GIT_WORK_TREE")
157+
158+ repo := t.TempDir()
159+ runGit(t, repo, "init")
160+ runGit(t, repo, "config", "user.email", "test@example.invalid")
161+ runGit(t, repo, "config", "user.name", "Test User")
162+ runGit(t, repo, "config", "commit.gpgsign", "false")
163+ if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o600); err != nil {
164+ t.Fatal(err)
165+ }
166+ runGit(t, repo, "add", "README.md")
167+ runGit(t, repo, "commit", "-m", "initial")
168+ return repo
169+}
170+
171+func unsetenvForTest(t *testing.T, name string) {
172+ t.Helper()
173+ value, ok := os.LookupEnv(name)
174+ if err := os.Unsetenv(name); err != nil {
175+ t.Fatal(err)
176+ }
177+ t.Cleanup(func() {
178+ if ok {
179+ _ = os.Setenv(name, value)
180+ } else {
181+ _ = os.Unsetenv(name)
182+ }
183+ })
184+}
185+
186+func runGit(t *testing.T, dir string, args ...string) {
187+ t.Helper()
188+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
189+ if output, err := cmd.CombinedOutput(); err != nil {
190+ t.Fatalf("git %v: %v\n%s", args, err, output)
191+ }
192+}
D packages/agent/swarm/agent.go +0 -228
@@ -1,228 +0,0 @@
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- Dir string // always the host's RepoRoot; agents share its cwd.
18- Started time.Time
19-
20- // Model and Provider, when non-empty, override the child
21- // subprocess's model resolution. Empty means "inherit whatever
22- // the child resolves on its own from config / env / flags" —
23- // the historical behaviour. Persisted in meta.json so Resume
24- // keeps using the same model across zot restarts.
25- Model string
26- Provider string
27- Reasoning string
28- FastMode bool
29- Subagent string
30-
31- // SessionID, when non-empty, scopes the agent to a particular
32- // host zot session: the dashboard only surfaces agents whose
33- // SessionID matches the active session. Empty means "unscoped"
34- // (legacy meta files from before the field existed, or agents
35- // spawned without a session context such as in tests). Set at
36- // Spawn time from Swarm.activeSession and persisted in
37- // meta.json so the scope survives restarts.
38- SessionID string
39-
40- // Resuming is true when this Agent struct was built by Resume
41- // rather than Spawn. The runner consults it to decide whether
42- // to pass the original Task as a positional argv to the child:
43- // on first Spawn we want the child to run the task immediately;
44- // on Resume the task was already run last time and replaying it
45- // would produce a second turn that collides ("agent busy; send
46- // 'cancel' first") with whatever the user types next. Not
47- // persisted — every Resume sets it explicitly.
48- Resuming bool
49-
50- // InboxPath is the unix socket the child agent listens on for
51- // follow-up prompts and control messages. The supervisor opens
52- // an Inbox at this path; the child opens a Listener.
53- InboxPath string
54-
55- // EventLogPath is the durable JSONL event log for this agent.
56- // The runner appends every well-formed event from the child
57- // (plus lifecycle events of its own) here. /swarm open in any
58- // zot process reads from this file to replay the full history.
59- EventLogPath string
60-
61- // SessionPath is the child's persistent session file. Surfaced
62- // so the dashboard / /swarm open can resume the agent later.
63- SessionPath string
64-
65- // inbox is the supervisor-side socket handle. Populated by
66- // Swarm.Spawn so SendInput can route messages without each
67- // caller redialing.
68- inbox *Inbox
69-
70- mu sync.Mutex
71- status Status
72- activity string
73- transcript []string
74- lastAssistant string
75- finished time.Time
76- lastErr error
77-
78- // OnTurnEnd, if set, fires once per prompt-level turn_end event
79- // emitted by the swarm daemon wrapper. Provider/tool-loop
80- // turn_end events (for example stop=tool_use) are ignored by the
81- // runner because they do not mean the sub-agent task finished.
82- // Used by auto-swarm watchers to detect that a sub-agent's first
83- // (or n-th) task has finished without waiting for the long-lived
84- // daemon itself to exit — sub-agents keep running on the inbox
85- // even after the initial task completes, so Wait() never unblocks
86- // for them.
87- OnTurnEnd func(step int, errMsg string)
88-
89- ctx context.Context
90- cancel context.CancelFunc
91- runner Runner
92-
93- // done closes when the run goroutine finalises the agent's
94- // status (done / failed / killed). Wait blocks on this so
95- // callers don't have to poll.
96- done chan struct{}
97-}
98-
99-// Inbox exposes the supervisor-side socket handle. Returns nil for
100-// agents without inboxes (e.g. tests using a custom Runner that
101-// doesn't speak the daemon protocol).
102-func (a *Agent) Inbox() *Inbox { return a.inbox }
103-
104-// Status returns the current high-level status. Cheap; safe from any goroutine.
105-func (a *Agent) Status() Status {
106- a.mu.Lock()
107- defer a.mu.Unlock()
108- return a.status
109-}
110-
111-// Activity returns the current one-line activity string.
112-func (a *Agent) Activity() string {
113- a.mu.Lock()
114- defer a.mu.Unlock()
115- return a.activity
116-}
117-
118-// Transcript returns a copy of the running transcript.
119-func (a *Agent) Transcript() []string {
120- a.mu.Lock()
121- defer a.mu.Unlock()
122- out := make([]string, len(a.transcript))
123- copy(out, a.transcript)
124- return out
125-}
126-
127-// Err returns the runner's terminal error, if any.
128-func (a *Agent) Err() error {
129- a.mu.Lock()
130- defer a.mu.Unlock()
131- return a.lastErr
132-}
133-
134-// Wait blocks until the agent reaches a terminal state. Used by tests
135-// and by /swarm wait <id>.
136-func (a *Agent) Wait() { <-a.done }
137-
138-// SetOnTurnEnd installs (or clears, with nil) the callback fired
139-// from the runner when the child daemon emits a prompt-level
140-// turn_end event with a step field. Safe to call from any goroutine:
141-// the runner reads the callback under the same mutex.
142-func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
143- a.mu.Lock()
144- a.OnTurnEnd = fn
145- a.mu.Unlock()
146-}
147-
148-func (a *Agent) setStatus(s Status) {
149- a.mu.Lock()
150- a.status = s
151- a.mu.Unlock()
152-}
153-
154-func (a *Agent) setActivity(msg string) {
155- a.mu.Lock()
156- a.activity = strings.TrimSpace(msg)
157- a.mu.Unlock()
158-}
159-
160-func (a *Agent) appendTranscript(chunk string) {
161- a.appendTranscriptLocked(chunk, "", false)
162-}
163-
164-func (a *Agent) appendUserMessage(text string) {
165- a.appendTranscriptLocked(text, "user: ", false)
166-}
167-
168-func (a *Agent) appendAssistantMessage(text string) {
169- a.appendTranscriptLocked(text, "", true)
170-}
171-
172-func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) {
173- message := chunk
174- chunk = strings.TrimRight(chunk, "\n")
175- if chunk == "" {
176- return
177- }
178- a.mu.Lock()
179- for _, line := range strings.Split(chunk, "\n") {
180- a.transcript = append(a.transcript, linePrefix+line)
181- }
182- if assistant {
183- a.lastAssistant = message
184- }
185- // Bound the transcript so dashboards don't hold gigabytes for
186- // long-running agents. 2000 lines is plenty for inspection;
187- // the durable record lives in the agent's session file.
188- const cap = 2000
189- if len(a.transcript) > cap {
190- a.transcript = a.transcript[len(a.transcript)-cap:]
191- }
192- a.mu.Unlock()
193-}
194-
195-// newAgentID returns a short, mostly-collision-free identifier of the
196-// form "<slug>-<nano>". The slug is derived from the task text so
197-// dashboards stay readable; the nano suffix guarantees uniqueness
198-// even when two agents are spawned in the same millisecond.
199-func newAgentID(task string, now time.Time) string {
200- slug := taskSlug(task)
201- return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000)
202-}
203-
204-func taskSlug(task string) string {
205- task = strings.ToLower(task)
206- var b strings.Builder
207- dash := false
208- for _, r := range task {
209- switch {
210- case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
211- b.WriteRune(r)
212- dash = false
213- default:
214- if !dash && b.Len() > 0 {
215- b.WriteByte('-')
216- dash = true
217- }
218- }
219- if b.Len() >= 24 {
220- break
221- }
222- }
223- out := strings.Trim(b.String(), "-")
224- if out == "" {
225- out = "agent"
226- }
227- return out
228-}
D packages/agent/swarm/event.go +0 -311
@@ -1,311 +0,0 @@
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- closed chan struct{}
238- once sync.Once
239-}
240-
241-// FollowEventLog starts polling path every interval. interval ≤ 0
242-// defaults to 50ms which is invisible to the user and cheap on
243-// disk (one stat + one short read per agent per tick).
244-func FollowEventLog(path string, interval time.Duration) *EventFollower {
245- if interval <= 0 {
246- interval = 50 * time.Millisecond
247- }
248- f := &EventFollower{
249- path: path,
250- interval: interval,
251- out: make(chan Event, 256),
252- done: make(chan struct{}),
253- closed: make(chan struct{}),
254- }
255- go f.loop()
256- return f
257-}
258-
259-// Events returns the receive end of the event stream.
260-func (f *EventFollower) Events() <-chan Event { return f.out }
261-
262-// Close stops polling and closes the events channel. It waits for
263-// the polling goroutine to exit so tests on Windows can remove the
264-// temp directory without racing an open file handle.
265-func (f *EventFollower) Close() {
266- f.once.Do(func() {
267- close(f.done)
268- <-f.closed
269- })
270-}
271-
272-func (f *EventFollower) loop() {
273- defer close(f.closed)
274- defer close(f.out)
275- var offset int64
276- tick := time.NewTicker(f.interval)
277- defer tick.Stop()
278- for {
279- select {
280- case <-f.done:
281- return
282- case <-tick.C:
283- }
284- fi, err := os.Stat(f.path)
285- if err != nil {
286- continue
287- }
288- if fi.Size() <= offset {
289- continue
290- }
291- fh, err := os.Open(f.path)
292- if err != nil {
293- continue
294- }
295- if _, err := fh.Seek(offset, io.SeekStart); err != nil {
296- _ = fh.Close()
297- continue
298- }
299- evs, _ := readEvents(fh)
300- newOffset, _ := fh.Seek(0, io.SeekCurrent)
301- _ = fh.Close()
302- offset = newOffset
303- for _, ev := range evs {
304- select {
305- case f.out <- ev:
306- case <-f.done:
307- return
308- }
309- }
310- }
311-}
D packages/agent/swarm/persist.go +0 -424
@@ -1,424 +0,0 @@
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-//
37-// Historical fields like `branch` and `isolated` are silently dropped
38-// by encoding/json's permissive decoder when an older meta.json is
39-// loaded; we don't need to keep them in the struct.
40-type agentMeta struct {
41- ID string `json:"id"`
42- Task string `json:"task"`
43- Dir string `json:"dir"`
44- Started time.Time `json:"started"`
45- Model string `json:"model,omitempty"`
46- Provider string `json:"provider,omitempty"`
47- Reasoning string `json:"reasoning,omitempty"`
48- FastMode *bool `json:"fast_mode,omitempty"`
49- Subagent string `json:"subagent,omitempty"`
50- InboxPath string `json:"inbox_path"`
51- EventLogPath string `json:"event_log_path"`
52- SessionPath string `json:"session_path"`
53-
54- // SessionID, when non-empty, scopes the agent to a particular
55- // host zot session: the dashboard only shows agents whose
56- // SessionID matches the active session. Older meta.json files
57- // (and agents spawned outside of any session, e.g. by tests or
58- // scripted callers that didn't call SetActiveSession) have an
59- // empty SessionID and are visible from every session as a
60- // backward-compat fallback.
61- SessionID string `json:"session_id,omitempty"`
62-}
63-
64-func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") }
65-
66-// writeAgentMeta serialises a's identity into stateDir/meta.json. The
67-// write is atomic (tmp + rename) so a crash mid-write can't leave a
68-// half-parsable file that fails Reload.
69-func writeAgentMeta(stateDir string, a *Agent) error {
70- fastMode := a.FastMode
71- m := agentMeta{
72- ID: a.ID,
73- Task: a.Task,
74- Dir: a.Dir,
75- Started: a.Started,
76- Model: a.Model,
77- Provider: a.Provider,
78- Reasoning: a.Reasoning,
79- FastMode: &fastMode,
80- Subagent: a.Subagent,
81- InboxPath: a.InboxPath,
82- EventLogPath: a.EventLogPath,
83- SessionPath: a.SessionPath,
84- SessionID: a.SessionID,
85- }
86- b, err := json.MarshalIndent(m, "", " ")
87- if err != nil {
88- return fmt.Errorf("swarm meta marshal: %w", err)
89- }
90- if err := os.MkdirAll(stateDir, 0o755); err != nil {
91- return fmt.Errorf("swarm meta dir: %w", err)
92- }
93- final := metaPath(stateDir)
94- tmp := final + ".tmp"
95- if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil {
96- return fmt.Errorf("swarm meta write: %w", err)
97- }
98- if err := os.Rename(tmp, final); err != nil {
99- _ = os.Remove(tmp)
100- return fmt.Errorf("swarm meta rename: %w", err)
101- }
102- return nil
103-}
104-
105-// readAgentMeta loads one meta.json. Returns os.ErrNotExist (wrapped)
106-// when the file is missing so callers can distinguish "no such agent"
107-// from "corrupt metadata".
108-func readAgentMeta(stateDir string) (agentMeta, error) {
109- var m agentMeta
110- b, err := os.ReadFile(metaPath(stateDir))
111- if err != nil {
112- return m, err
113- }
114- if err := json.Unmarshal(b, &m); err != nil {
115- return m, fmt.Errorf("swarm meta parse %s: %w", stateDir, err)
116- }
117- if m.ID == "" {
118- return m, fmt.Errorf("swarm meta %s: missing id", stateDir)
119- }
120- return m, nil
121-}
122-
123-// Reload scans <root>/agents/*/meta.json and re-registers every
124-// previously-spawned agent as a StatusDetached entry. Agents already
125-// present in memory are left alone (Reload is idempotent and safe to
126-// call after Spawn, though in practice the cli invokes it exactly
127-// once just after New()).
128-//
129-// The reloaded agents have no live Runner; the user can:
130-// - view their transcript (the dashboard reads from EventLogPath),
131-// - resume them via Swarm.Resume (starts a fresh subprocess on the
132-// same worktree / session / inbox path),
133-// - remove them (worktree + meta + events log gone).
134-//
135-// Reload returns the number of agents loaded plus any per-directory
136-// error encountered. Malformed entries are skipped rather than
137-// failing the whole reload — one bad meta.json shouldn't hide the
138-// rest of the swarm.
139-func (f *Swarm) Reload() (loaded int, errs []error) {
140- agentsDir := filepath.Join(f.cfg.Root, "agents")
141- entries, err := os.ReadDir(agentsDir)
142- if err != nil {
143- if errors.Is(err, os.ErrNotExist) {
144- return 0, nil
145- }
146- return 0, []error{fmt.Errorf("swarm reload: %w", err)}
147- }
148-
149- // Sort by directory name so the load order is stable across runs.
150- // agentStateDir uses the id verbatim so name-sort == id-sort,
151- // which mirrors the creation order well enough for the dashboard
152- // (we also sort by Started in SnapshotAll, but having a stable
153- // f.order helps tests).
154- names := make([]string, 0, len(entries))
155- for _, e := range entries {
156- if e.IsDir() {
157- names = append(names, e.Name())
158- }
159- }
160- sort.Strings(names)
161-
162- for _, name := range names {
163- stateDir := filepath.Join(agentsDir, name)
164- m, err := readAgentMeta(stateDir)
165- if err != nil {
166- if errors.Is(err, os.ErrNotExist) {
167- // Bare directory with no meta.json — probably a
168- // leftover from a Spawn that failed before
169- // writeAgentMeta. Ignore silently.
170- continue
171- }
172- errs = append(errs, err)
173- continue
174- }
175-
176- f.mu.Lock()
177- if _, exists := f.agents[m.ID]; exists {
178- f.mu.Unlock()
179- continue
180- }
181- a := f.buildDetachedAgent(m)
182- f.agents[m.ID] = a
183- f.order = append(f.order, m.ID)
184- f.mu.Unlock()
185- loaded++
186- }
187- return loaded, errs
188-}
189-
190-// buildDetachedAgent constructs an Agent from a meta.json with no
191-// running Runner. The agent's transcript is populated from the tail
192-// of its event log so the dashboard immediately shows recent output;
193-// activity is inferred from the last lifecycle event.
194-//
195-// The returned Agent has a closed `done` channel because Wait should
196-// return instantly: there is nothing to wait for.
197-func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
198- // Older meta.json files may still record a per-agent worktree
199- // path under Dir. They predate the decision to run every agent
200- // in the host's repo and shouldn't continue editing that stale
201- // checkout, which most likely no longer matches HEAD. Coerce
202- // the dir back to the live RepoRoot so resume picks up where
203- // the host is now.
204- dir := m.Dir
205- if f.cfg.RepoRoot != "" {
206- dir = f.cfg.RepoRoot
207- }
208- fastMode := f.cfg.FastMode
209- if m.FastMode != nil {
210- fastMode = *m.FastMode
211- }
212- a := &Agent{
213- ID: m.ID,
214- Task: m.Task,
215- Dir: dir,
216- Started: m.Started,
217- Model: m.Model,
218- Provider: m.Provider,
219- Reasoning: m.Reasoning,
220- FastMode: fastMode,
221- Subagent: m.Subagent,
222- InboxPath: m.InboxPath,
223- EventLogPath: m.EventLogPath,
224- SessionPath: m.SessionPath,
225- SessionID: m.SessionID,
226- inbox: NewInbox(m.InboxPath),
227- status: StatusDetached,
228- activity: "detached",
229- done: make(chan struct{}),
230- }
231- // Wait() must not block for detached agents; close the channel
232- // immediately. Callers Resuming the agent will replace done with
233- // a fresh channel before starting the new runner.
234- close(a.done)
235-
236- // Recover transcript + activity hints from the event log. Best
237- // effort: a missing or unreadable log just leaves the agent
238- // detached with an empty transcript.
239- if a.EventLogPath != "" {
240- if evs, err := ReadEventLog(a.EventLogPath); err == nil {
241- replayEventsIntoAgent(a, evs)
242- }
243- }
244- return a
245-}
246-
247-// replayEventsIntoAgent re-derives an agent's transcript and last
248-// known status hint from its event log. Mirrors applyEventToSink in
249-// runner.go but writes directly to the Agent fields (no Sink because
250-// the agent isn't being driven by a runner yet).
251-//
252-// Status precedence: explicit lifecycle events (agent_stopped) win
253-// over inferred ones (assistant_message → idle). If the log contains
254-// no terminator we keep status=StatusDetached so the user can resume.
255-func replayEventsIntoAgent(a *Agent, evs []Event) {
256- terminal := false
257- for _, ev := range evs {
258- switch ev.Type {
259- case "assistant_message", "user_message":
260- var text []string
261- if c, ok := ev.Data["content"].([]any); ok {
262- for _, blk := range c {
263- m, _ := blk.(map[string]any)
264- if t, _ := m["type"].(string); t == "text" {
265- if txt, _ := m["text"].(string); txt != "" {
266- text = append(text, txt)
267- }
268- }
269- }
270- }
271- message := strings.Join(text, "\n")
272- if ev.Type == "assistant_message" {
273- a.appendAssistantMessage(message)
274- } else {
275- a.appendUserMessage(message)
276- }
277- case "stdout":
278- if txt, _ := ev.Data["text"].(string); txt != "" {
279- a.appendTranscript(txt)
280- }
281- case "stderr":
282- if txt, _ := ev.Data["text"].(string); txt != "" {
283- a.appendTranscript("stderr: " + txt)
284- }
285- case "error":
286- if msg, _ := ev.Data["message"].(string); msg != "" {
287- a.appendTranscript("error: " + msg)
288- }
289- case "agent_stopped":
290- terminal = true
291- reason, _ := ev.Data["reason"].(string)
292- a.mu.Lock()
293- switch reason {
294- case "cancelled":
295- a.status = StatusKilled
296- a.activity = "cancelled (offline)"
297- case "shutdown":
298- a.status = StatusDone
299- a.activity = "shutdown (offline)"
300- case "exit":
301- if code, ok := ev.Data["code"].(float64); ok && code != 0 {
302- a.status = StatusFailed
303- a.activity = fmt.Sprintf("exit %d (offline)", int(code))
304- } else {
305- a.status = StatusDone
306- a.activity = "done (offline)"
307- }
308- default:
309- a.status = StatusDone
310- a.activity = "stopped (offline)"
311- }
312- a.mu.Unlock()
313- }
314- }
315- if !terminal {
316- // Non-terminal log means the previous parent died mid-run.
317- // Leave status=StatusDetached but record a hint so the
318- // dashboard shows something useful.
319- a.mu.Lock()
320- if a.activity == "detached" && len(a.transcript) > 0 {
321- a.activity = "detached (resume to continue)"
322- }
323- a.mu.Unlock()
324- }
325-}
326-
327-// Resume re-attaches a Runner to a previously-spawned agent. Durable
328-// session and event files are kept, while the transient inbox path is
329-// recalculated for the current runtime environment. Use this to
330-// continue a swarm session across zot restarts:
331-//
332-// swarmMgr.Reload()
333-// a, err := swarmMgr.Resume(ctx, "alpha-12345")
334-// swarmMgr.SendUserTurn(a.ID, "where were we?")
335-//
336-// The agent must be in a non-running state (Detached, Done, Failed,
337-// Killed). Resuming a still-running agent returns an error so two
338-// runners don't race for the same session.
339-func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
340- existing := f.Get(id)
341- if existing == nil {
342- return nil, fmt.Errorf("swarm: no such agent %q", id)
343- }
344- existing.mu.Lock()
345- st := existing.status
346- existing.mu.Unlock()
347- if st == StatusRunning || st == StatusPending {
348- return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st)
349- }
350-
351- // Rebuild from the meta record so we don't carry stale runner
352- // state from a previous incarnation. The inbox is transient and
353- // may have been persisted under an incompatible filesystem by an
354- // older zot version, so always select it again on resume.
355- inboxPath, err := inboxSocketPath(f.cfg.Root, existing.ID)
356- if err != nil {
357- return nil, fmt.Errorf("swarm inbox path: %w", err)
358- }
359- fastMode := existing.FastMode
360- m := agentMeta{
361- ID: existing.ID, Task: existing.Task,
362- Dir: existing.Dir, Started: existing.Started,
363- Model: existing.Model, Provider: existing.Provider,
364- Reasoning: existing.Reasoning, FastMode: &fastMode,
365- Subagent: existing.Subagent,
366- SessionID: existing.SessionID,
367- InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
368- SessionPath: existing.SessionPath,
369- }
370-
371- a := &Agent{
372- ID: m.ID,
373- Task: m.Task,
374- Dir: m.Dir,
375- Started: m.Started,
376- Model: m.Model,
377- Provider: m.Provider,
378- Reasoning: m.Reasoning,
379- FastMode: fastMode,
380- Subagent: m.Subagent,
381- SessionID: m.SessionID,
382- InboxPath: m.InboxPath,
383- EventLogPath: m.EventLogPath,
384- SessionPath: m.SessionPath,
385- Resuming: true,
386- inbox: NewInbox(m.InboxPath),
387- status: StatusPending,
388- activity: "resuming",
389- done: make(chan struct{}),
390- }
391- // Carry the previous transcript forward so the dashboard doesn't
392- // flash empty between resume and the first new event.
393- prev := existing.Transcript()
394- if len(prev) > 0 {
395- a.appendTranscript(strings.Join(prev, "\n"))
396- }
397- a.ctx, a.cancel = context.WithCancel(ctx)
398- a.runner = f.cfg.NewRunner(a)
399-
400- f.mu.Lock()
401- f.agents[a.ID] = a
402- // Keep the agent's slot in f.order; replacing in-place avoids
403- // reshuffling the dashboard's row ordering on resume.
404- found := false
405- for _, k := range f.order {
406- if k == a.ID {
407- found = true
408- break
409- }
410- }
411- if !found {
412- f.order = append(f.order, a.ID)
413- }
414- f.mu.Unlock()
415-
416- // Refresh the meta.json so any new path defaults (e.g. socket
417- // path moved into /tmp because the root got renamed) get
418- // persisted. Best-effort; resume still works if the disk is
419- // read-only because everything the runner needs is in-memory.
420- _ = writeAgentMeta(f.agentStateDir(a.ID), a)
421-
422- go f.run(a)
423- return a, nil
424-}
D packages/agent/swarm/runner.go +0 -444
@@ -1,444 +0,0 @@
1-package swarm
2-
3-import (
4- "bufio"
5- "bytes"
6- "context"
7- "encoding/json"
8- "fmt"
9- "io"
10- "os"
11- "os/exec"
12- "path/filepath"
13- "strings"
14- "time"
15-)
16-
17-// execRunner spawns `zot --swarm-agent <inbox> --session <path>` in
18-// the host's working directory (Agent.Dir, which is always the parent
19-// zot's RepoRoot) and consumes its JSONL event stream on stdout.
20-//
21-// Why a long-lived daemon and not `zot --print`: the supervisor and
22-// the user expect agents to keep accepting follow-up prompts. A
23-// one-shot subprocess can't do that; this design gives each swarm
24-// agent a persistent session file plus an inbox socket the parent
25-// writes to, mirroring Claude Code's "Agents view" model.
26-//
27-// Events flow:
28-//
29-// child stdout --> decoder --> EventLog (events.jsonl)
30-// --> Sink (Activity/Transcript)
31-//
32-// The on-disk log is the durable record. The Sink updates are an
33-// in-memory mirror so the dashboard doesn't have to tail the file
34-// for the parent's own agents. /swarm open in a separate zot would
35-// read the log directly.
36-type execRunner struct {
37- agent *Agent
38- resolveCredential func(context.Context, string) (Credential, error)
39-
40- // Command overrides the default `zot --swarm-agent ...`
41- // invocation. Tests set this to a fake binary (or `go run`
42- // against a tiny stub program) so the supervisor logic can be
43- // tested without a real child. Production code leaves it nil.
44- Command []string
45-
46- // SessionPath is the agent's session file. Empty means "defer
47- // to r.agent.SessionPath", which Swarm.Spawn always populates
48- // with <swarm-root>/agents/<id>/session.json. Tests that
49- // hand-build an Agent without going through Spawn must set
50- // one of the two; the runner refuses to invent a fallback
51- // because the only plausible one (<Dir>/.zot/session.json)
52- // would litter the user's repo — every agent's Dir points
53- // at it directly.
54- SessionPath string
55-}
56-
57-// swarmAgentArgsOpts captures every dynamic input to swarmAgentArgs.
58-// The fields map 1:1 onto child CLI flags; empty values omit the flag
59-// entirely and let the child resolve a default the same way a normal
60-// `zot` invocation does.
61-type swarmAgentArgsOpts struct {
62- Exe string
63- Dir string
64- SessionPath string
65- InboxPath string
66- Task string
67- Model string
68- Provider string
69- Reasoning string
70- FastMode bool
71- FastModeSet bool
72- Subagent string
73-}
74-
75-// defaultChildArgs builds the argv execRunner uses when its Command
76-// override is empty. Centralised so the spawn-vs-resume decision
77-// (whether to pass the original Task as a positional) lives in one
78-// place that tests can hit directly without going through Run's
79-// side effects.
80-//
81-// On Spawn (Resuming==false) we pass the task so the child's first
82-// turn runs immediately. On Resume (Resuming==true) we omit it: the
83-// child reopens the existing session file, loads the prior
84-// conversation, and just waits on the inbox for the next prompt.
85-// Re-firing the task on every resume produces a duplicate turn that
86-// collides with whatever the user types next, surfacing the
87-// "agent busy; send 'cancel' first" error between two assistant
88-// messages — which is exactly the bug this helper fixes.
89-func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string {
90- task := a.Task
91- if a.Resuming {
92- task = ""
93- }
94- return swarmAgentArgs(swarmAgentArgsOpts{
95- Exe: exe,
96- Dir: a.Dir,
97- SessionPath: sessionPath,
98- InboxPath: inboxPath,
99- Task: task,
100- Model: a.Model,
101- Provider: a.Provider,
102- Reasoning: a.Reasoning,
103- FastMode: a.FastMode,
104- FastModeSet: true,
105- Subagent: a.Subagent,
106- })
107-}
108-
109-// swarmAgentArgs builds the argv used when execRunner.Command is
110-// empty. Pulled out so tests can lock in the flag set without
111-// actually spawning a subprocess.
112-func swarmAgentArgs(opts swarmAgentArgsOpts) []string {
113- args := []string{
114- opts.Exe,
115- "--swarm-agent", opts.InboxPath,
116- "--session", opts.SessionPath,
117- "--cwd", opts.Dir,
118- }
119- if opts.Model != "" {
120- args = append(args, "--model", opts.Model)
121- }
122- if opts.Provider != "" {
123- args = append(args, "--provider", opts.Provider)
124- }
125- if opts.Reasoning != "" {
126- args = append(args, "--reasoning", opts.Reasoning)
127- }
128- if opts.FastModeSet {
129- if opts.FastMode {
130- args = append(args, "--fast-mode")
131- } else {
132- args = append(args, "--no-fast-mode")
133- }
134- }
135- if opts.Subagent != "" {
136- args = append(args, "--subagent", opts.Subagent)
137- }
138- if opts.Task != "" {
139- // First task is positional so the child treats it as the
140- // initial user turn; subsequent turns arrive via the inbox.
141- args = append(args, opts.Task)
142- }
143- return args
144-}
145-
146-func (r *execRunner) Run(ctx context.Context, sink Sink) error {
147- // SessionPath resolution order:
148- // 1. explicit r.SessionPath set by the test / caller
149- // 2. r.agent.SessionPath baked in by Swarm.Spawn — the
150- // production path. Always lives under
151- // <swarm-root>/agents/<id>/session.json so the per-
152- // agent state is entirely outside the working tree.
153- // Crucial because Agent.Dir points at the user's repo;
154- // any .zot/ scratch directory under Dir would litter
155- // their source tree.
156- //
157- // There is no third fallback. If neither path is set we
158- // refuse to start instead of inventing a directory; that
159- // way a misconfigured caller fails loudly the first time
160- // instead of silently dumping session data into someone's
161- // repo.
162- sessionPath := r.SessionPath
163- if sessionPath == "" {
164- sessionPath = r.agent.SessionPath
165- }
166- if sessionPath == "" {
167- return fmt.Errorf("swarm: agent missing session path (set SpawnRequest via Swarm.SpawnReq, or hand-build Agent with SessionPath populated)")
168- }
169- if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
170- return fmt.Errorf("session dir: %w", err)
171- }
172-
173- inboxPath := r.agent.InboxPath
174- logPath := r.agent.EventLogPath
175- if logPath == "" {
176- return fmt.Errorf("swarm: agent missing event log path")
177- }
178- log, err := OpenEventLog(logPath)
179- if err != nil {
180- return err
181- }
182- defer log.Close()
183-
184- args := r.Command
185- if len(args) == 0 {
186- exe, err := os.Executable()
187- if err != nil {
188- return fmt.Errorf("locate self: %w", err)
189- }
190- args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath)
191- }
192-
193- cmd := exec.CommandContext(ctx, args[0], args[1:]...)
194- cmd.Dir = r.agent.Dir
195- cmd.Env = append(os.Environ(),
196- "ZOT_SWARM_AGENT_ID="+r.agent.ID,
197- "ZOT_SWARM_EVENT_LOG="+logPath,
198- )
199- if r.resolveCredential != nil {
200- credential, resolveErr := r.resolveCredential(ctx, r.agent.Provider)
201- if resolveErr != nil {
202- return fmt.Errorf("resolve swarm credential for %s: %w", r.agent.Provider, resolveErr)
203- }
204- if credential.Value != "" {
205- encoded, encodeErr := json.Marshal(credential)
206- if encodeErr != nil {
207- return fmt.Errorf("encode swarm credential: %w", encodeErr)
208- }
209- cmd.Stdin = bytes.NewReader(encoded)
210- cmd.Env = append(cmd.Env, "ZOT_SWARM_CREDENTIAL_STDIN=1")
211- }
212- }
213-
214- stdout, err := cmd.StdoutPipe()
215- if err != nil {
216- return err
217- }
218- stderr, err := cmd.StderrPipe()
219- if err != nil {
220- return err
221- }
222-
223- // "spawning" is briefly shown until the first event arrives;
224- // the child's "spawned" lifecycle event then overwrites it.
225- sink.Activity("starting")
226- if err := cmd.Start(); err != nil {
227- return err
228- }
229-
230- // stdout: parsed as JSONL. Every well-formed event is appended
231- // to the durable log AND forwarded to the in-memory sink so the
232- // dashboard updates without having to tail the file. Malformed
233- // lines are surfaced as plain transcript so they don't vanish.
234- done := make(chan struct{}, 2)
235- go func() {
236- defer func() { done <- struct{}{} }()
237- dec := bufio.NewReader(stdout)
238- for {
239- line, err := dec.ReadBytes('\n')
240- if len(line) > 0 {
241- trimmed := strings.TrimRight(string(line), "\r\n")
242- if trimmed == "" {
243- goto next
244- }
245- if ev, ok := parseEventLine(trimmed); ok {
246- _ = log.Append(ev)
247- applyEventToSink(ev, sink)
248- // Fan prompt-level task completions up to any
249- // subscriber on the supervised Agent. The child
250- // also forwards provider/tool-loop turn_end
251- // events (for example stop=tool_use); those do
252- // not contain step and must not be treated as
253- // swarm task completion.
254- notifyPromptTurnEnd(r.agent, ev)
255- } else {
256- // Non-JSON output. Keep it as transcript so an
257- // accidental fmt.Println in the child still
258- // shows up in the dashboard, and log a
259- // lifecycle event so the durable record stays
260- // in sync.
261- sink.Transcript(trimmed)
262- _ = log.Append(NewEvent("stdout", map[string]any{"text": trimmed}))
263- }
264- }
265- next:
266- if err != nil {
267- return
268- }
269- }
270- }()
271-
272- // stderr: lifecycle/error chatter from the child. Every line
273- // is mirrored as a stderr event in the durable log AND surfaced
274- // in the transcript so users can diagnose a failing agent
275- // without leaving the dashboard.
276- go func() {
277- defer func() { done <- struct{}{} }()
278- br := bufio.NewReader(stderr)
279- for {
280- line, err := br.ReadString('\n')
281- if line != "" {
282- txt := strings.TrimRight(line, "\r\n")
283- sink.Transcript("stderr: " + txt)
284- _ = log.Append(NewEvent("stderr", map[string]any{"text": txt}))
285- }
286- if err != nil {
287- return
288- }
289- }
290- }()
291-
292- <-done
293- <-done
294-
295- err = cmd.Wait()
296- exit := 0
297- if ee, ok := err.(*exec.ExitError); ok {
298- exit = ee.ExitCode()
299- }
300- if err != nil && ctx.Err() != nil {
301- _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"}))
302- return ctx.Err()
303- }
304- if err != nil {
305- _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()}))
306- return err
307- }
308- _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0}))
309- sink.Activity("done")
310- return nil
311-}
312-
313-// parseEventLine attempts to decode one JSONL line as an Event.
314-// Returns ok=false for non-JSON or JSON without a "type" field.
315-func parseEventLine(line string) (Event, bool) {
316- if len(line) == 0 || line[0] != '{' {
317- return Event{}, false
318- }
319- var ev Event
320- if err := json.Unmarshal([]byte(line), &ev); err != nil {
321- return Event{}, false
322- }
323- if ev.Type == "" {
324- return Event{}, false
325- }
326- if ev.Time.IsZero() {
327- ev.Time = time.Now()
328- }
329- return ev, true
330-}
331-
332-// notifyPromptTurnEnd calls Agent.OnTurnEnd only for the swarm
333-// daemon's prompt-level completion event. Provider/tool-loop
334-// turn_end events (such as stop=tool_use) do not include step and
335-// are not terminal for the delegated task.
336-func notifyPromptTurnEnd(a *Agent, ev Event) {
337- if a == nil || ev.Type != "turn_end" {
338- return
339- }
340- step, ok := ev.Data["step"].(float64)
341- if !ok {
342- return
343- }
344-
345- a.mu.Lock()
346- fn := a.OnTurnEnd
347- a.mu.Unlock()
348- if fn != nil {
349- errMsg, _ := ev.Data["error"].(string)
350- go fn(int(step), errMsg)
351- }
352-}
353-
354-// applyEventToSink translates an Event into Sink updates. Only a
355-// few event types are interpreted; the rest still land in the
356-// durable log via the caller.
357-func applyEventToSink(ev Event, sink Sink) {
358- type roleSink interface {
359- userMessage(string)
360- assistantMessage(string)
361- }
362- appendMessage := func(text string, assistant bool) {
363- if text == "" {
364- return
365- }
366- if roles, ok := sink.(roleSink); ok {
367- if assistant {
368- roles.assistantMessage(text)
369- } else {
370- roles.userMessage(text)
371- }
372- return
373- }
374- if assistant {
375- sink.Transcript(text)
376- } else {
377- sink.Transcript("user: " + text)
378- }
379- }
380-
381- switch ev.Type {
382- case "assistant_message", "user_message":
383- var text []string
384- if c, ok := ev.Data["content"].([]any); ok {
385- for _, blk := range c {
386- m, _ := blk.(map[string]any)
387- if t, _ := m["type"].(string); t == "text" {
388- if txt, _ := m["text"].(string); txt != "" {
389- text = append(text, txt)
390- }
391- }
392- }
393- }
394- appendMessage(strings.Join(text, "\n"), ev.Type == "assistant_message")
395- if ev.Type == "assistant_message" {
396- sink.Activity("idle")
397- }
398- case "turn_start":
399- sink.Activity("thinking")
400- case "tool_call":
401- if name, _ := ev.Data["name"].(string); name != "" {
402- sink.Activity("tool: " + truncate(name, 60))
403- }
404- case "tool_result":
405- sink.Activity("idle")
406- case "turn_end":
407- sink.Activity("idle")
408- case "agent_ready":
409- sink.Activity("idle")
410- case "agent_stopped":
411- // terminal status is decided by Swarm.run from the runner's
412- // return value, not from this event. Don't overwrite the
413- // activity here.
414- case "error":
415- if msg, _ := ev.Data["message"].(string); msg != "" {
416- sink.Transcript("error: " + msg)
417- }
418- }
419-}
420-
421-// RunnerFunc adapts a plain function into a Runner. Useful for tests
422-// and for callers who don't need their own type.
423-type RunnerFunc func(ctx context.Context, sink Sink) error
424-
425-func (f RunnerFunc) Run(ctx context.Context, sink Sink) error { return f(ctx, sink) }
426-
427-// streamLines kept around for any caller still using it directly.
428-//
429-// Deprecated: the runner now parses JSONL from stdout via
430-// parseEventLine; this helper is unused inside the package but
431-// remains exported via internal use by tests in the runner_test
432-// suite that pre-date the daemon switch.
433-func streamLines(r io.Reader, fn func(string)) {
434- br := bufio.NewReader(r)
435- for {
436- line, err := br.ReadString('\n')
437- if line != "" {
438- fn(strings.TrimRight(line, "\r\n"))
439- }
440- if err != nil {
441- return
442- }
443- }
444-}
D packages/agent/swarm/swarm.go +0 -606
@@ -1,606 +0,0 @@
1-// Package swarm implements zot's multi-agent supervisor.
2-//
3-// A Swarm manages a set of headless zot subprocesses ("agents")
4-// that share the host's working directory. The interactive TUI
5-// exposes the supervisor through the /swarm slash command and a
6-// dashboard dialog; non-TUI code can drive it directly through
7-// this package.
8-//
9-// Every agent runs with cwd == the parent zot's RepoRoot — the
10-// same files the user sees, the same files the main agent edits.
11-// There is no git worktree, no per-agent branch, no isolation. If
12-// you want parallel edits on a separate branch, use normal git
13-// tooling (a real worktree, a different terminal) yourself.
14-//
15-// Each Agent has:
16-// - a unique id (short slug + nanoseconds)
17-// - a Runner (the thing that actually executes the task)
18-// - a Status string + Activity string that the dashboard reads
19-//
20-// The Runner abstraction means tests can swap a fake in instead of
21-// really spawning a subprocess; the production Runner shells out to
22-// `zot --swarm-agent ...` so we reuse zot's own model resolution
23-// and tooling without re-implementing the agent loop.
24-package swarm
25-
26-import (
27- "context"
28- "errors"
29- "fmt"
30- "os"
31- "path/filepath"
32- "sort"
33- "strings"
34- "sync"
35- "time"
36-)
37-
38-// Status is the high-level lifecycle state of an Agent.
39-type Status string
40-
41-const (
42- StatusPending Status = "pending" // created, not started yet
43- StatusRunning Status = "running" // Runner.Run is in flight
44- StatusDone Status = "done" // Runner.Run returned nil
45- StatusFailed Status = "failed" // Runner.Run returned an error
46- StatusKilled Status = "killed" // Stop() called before completion
47- StatusDetached Status = "detached" // reloaded from disk; no live runner
48-)
49-
50-// Config configures a Swarm.
51-type Config struct {
52- // Root is the directory under which per-agent state files live.
53- // Typically <ZotHome>/swarm, but tests pass a tempdir.
54- Root string
55-
56- // RepoRoot is the working directory every spawned agent runs
57- // in — the same cwd the parent zot is using. There is no
58- // per-agent isolation: agents edit the host's files directly.
59- RepoRoot string
60-
61- // FastMode is copied to every newly spawned child and persisted so
62- // child subprocesses use the same OpenAI service-tier setting.
63- FastMode bool
64-
65- // NewRunner produces the Runner for an Agent. If nil, the default
66- // `zot --swarm-agent ...` exec runner is used. Tests inject a fake
67- // here.
68- NewRunner func(a *Agent) Runner
69-
70- // ResolveCredential resolves the credential inherited by a child
71- // process. The default runner transfers it over the child's stdin,
72- // keeping command-backed credentials out of argv and the environment.
73- ResolveCredential func(ctx context.Context, provider string) (Credential, error)
74-
75- // Now is a clock seam for tests; defaults to time.Now.
76- Now func() time.Time
77-}
78-
79-// Credential is the resolved authentication state inherited by a swarm child.
80-type Credential struct {
81- Value string `json:"value"`
82- Method string `json:"method"`
83- AccountID string `json:"account_id,omitempty"`
84-}
85-
86-// Runner executes one agent task. Run blocks until the task finishes,
87-// is cancelled via ctx, or hits an unrecoverable error.
88-//
89-// Run should report progress by writing short human-readable strings
90-// to the activity channel and final transcript text to transcript.
91-// Both channels are non-blocking sinks owned by the Swarm; if the
92-// dashboard isn't reading, sends are dropped.
93-type Runner interface {
94- Run(ctx context.Context, sink Sink) error
95-}
96-
97-// Sink is how a Runner reports activity and transcript back to the
98-// supervisor. All methods are safe to call from any goroutine and
99-// never block.
100-type Sink interface {
101- // Activity sets the one-line "what is this agent doing right now"
102- // string shown in the dashboard.
103- Activity(msg string)
104- // Transcript appends a chunk of agent output (typically a final
105- // assistant message) to the agent's running transcript.
106- Transcript(chunk string)
107-}
108-
109-// Swarm supervises a set of Agents.
110-type Swarm struct {
111- cfg Config
112-
113- mu sync.Mutex
114- agents map[string]*Agent
115- order []string // creation order for stable listing
116-
117- // activeSession is the host session id the dashboard is
118- // currently scoped to. When non-empty, SnapshotAll filters out
119- // agents whose SessionID doesn't match (legacy / unscoped
120- // agents with SessionID == "" are always shown). Spawn stamps
121- // new agents with this value so they appear only in the session
122- // that created them. When empty (the default), the historical
123- // "show everything" behaviour is preserved — important for
124- // tests and any scripted use of the Swarm that doesn't bother
125- // with sessions.
126- activeSession string
127-}
128-
129-// New constructs a Swarm from cfg. Missing config fields are filled
130-// with defaults.
131-func New(cfg Config) *Swarm {
132- if cfg.Now == nil {
133- cfg.Now = time.Now
134- }
135- if cfg.NewRunner == nil {
136- cfg.NewRunner = func(a *Agent) Runner {
137- return &execRunner{agent: a, resolveCredential: cfg.ResolveCredential}
138- }
139- }
140- return &Swarm{
141- cfg: cfg,
142- agents: map[string]*Agent{},
143- }
144-}
145-
146-// SetRepoRoot updates the shared working directory for subsequently
147-// spawned agents. Existing agents keep the directory they were started
148-// with; callers use this when the host session changes cwd.
149-func (f *Swarm) SetRepoRoot(root string) {
150- f.mu.Lock()
151- defer f.mu.Unlock()
152- f.cfg.RepoRoot = root
153-}
154-
155-// RepoRoot returns the working directory used by subsequently spawned
156-// agents.
157-func (f *Swarm) RepoRoot() string {
158- f.mu.Lock()
159- defer f.mu.Unlock()
160- return f.cfg.RepoRoot
161-}
162-
163-// SetFastMode updates the fast-mode setting for subsequently spawned
164-// children. Existing agents keep the setting captured at their spawn
165-// boundary so resumes remain deterministic.
166-func (f *Swarm) SetFastMode(enabled bool) {
167- f.mu.Lock()
168- f.cfg.FastMode = enabled
169- f.mu.Unlock()
170-}
171-
172-// FastMode reports the setting used for subsequently spawned children.
173-func (f *Swarm) FastMode() bool {
174- f.mu.Lock()
175- defer f.mu.Unlock()
176- return f.cfg.FastMode
177-}
178-
179-// SetActiveSession scopes the dashboard view (and Spawn stamping)
180-// to a particular host zot session id. Pass empty to clear the
181-// scope and revert to "show every agent" (the original behaviour).
182-//
183-// Existing in-memory agents keep their SessionID; only the filter
184-// applied at snapshot time changes. So swapping the active session
185-// with /sessions instantly re-narrows the dashboard without
186-// touching any agent state.
187-func (f *Swarm) SetActiveSession(id string) {
188- f.mu.Lock()
189- defer f.mu.Unlock()
190- f.activeSession = id
191-}
192-
193-// ActiveSession returns the current scope, mostly for tests and
194-// diagnostics. Empty means "no scope; show everything".
195-func (f *Swarm) ActiveSession() string {
196- f.mu.Lock()
197- defer f.mu.Unlock()
198- return f.activeSession
199-}
200-
201-// agentStateDir is the per-agent state directory laid out as:
202-//
203-// <root>/agents/<id>/
204-// events.jsonl durable event log (runner-owned)
205-// session.json persistent agent session (child-owned)
206-// meta.json static metadata (id, task)
207-//
208-// The transient unix socket inbox lives in a probed runtime directory,
209-// which may be on a different filesystem from this durable state.
210-func (f *Swarm) agentStateDir(id string) string {
211- return filepath.Join(f.cfg.Root, "agents", id)
212-}
213-
214-// SpawnRequest configures a Spawn. Only Task is required; the rest
215-// are optional. Model + Provider, when set, get baked into the
216-// child argv as --model / --provider so the agent runs against the
217-// chosen model regardless of the parent's current selection. A named
218-// Subagent is passed to the child as --subagent, where it resolves the
219-// markdown profile and applies its instructions. Reasoning is passed
220-// as --reasoning when set. FastMode restricts the host setting when a
221-// selected profile declares a fastMode preference.
222-type SpawnRequest struct {
223- Task string
224- Model string // optional override; child resolves default if empty
225- Provider string // optional override; usually paired with Model
226- Reasoning string // optional reasoning/thinking level override
227- // FastMode is an optional profile restriction. A non-nil false value
228- // disables fast mode for this child; true still cannot enable fast mode
229- // when Config.FastMode is false. Nil inherits Config.FastMode.
230- FastMode *bool
231- Subagent string // optional named markdown profile
232-}
233-
234-// Spawn creates a new Agent for the given task, allocates its
235-// on-disk state directory (events log, inbox socket path, session
236-// file path), and starts the Runner on a background goroutine. The
237-// returned Agent is already in StatusRunning (or StatusFailed if
238-// state setup failed before the goroutine started). This is the
239-// historical signature; callers that want to override the child's
240-// model use SpawnReq instead.
241-func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) {
242- return f.SpawnReq(ctx, SpawnRequest{Task: task})
243-}
244-
245-// SpawnReq is the full-fat variant of Spawn that accepts a
246-// SpawnRequest. Existing callers can keep using Spawn; new code that
247-// wants to pin the child's model uses this.
248-//
249-// Every spawned agent runs with cwd == cfg.RepoRoot — the same
250-// working directory as the host. No per-agent worktree, no branch,
251-// no isolation. The user explicitly opted out of the worktree flow.
252-func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) {
253- task := strings.TrimSpace(req.Task)
254- if task == "" {
255- return nil, errors.New("swarm: empty task")
256- }
257- id := newAgentID(task, f.cfg.Now())
258-
259- // Snapshot the shared cwd together with the active session so a
260- // concurrent /cd cannot race a spawn or produce a mixed snapshot.
261- f.mu.Lock()
262- dir := f.cfg.RepoRoot
263- fastMode := f.cfg.FastMode
264- sessionID := f.activeSession
265- f.mu.Unlock()
266- if req.FastMode != nil {
267- fastMode = fastMode && *req.FastMode
268- }
269-
270- stateDir := f.agentStateDir(id)
271- if err := os.MkdirAll(stateDir, 0o755); err != nil {
272- return nil, fmt.Errorf("swarm state dir: %w", err)
273- }
274- logPath := filepath.Join(stateDir, "events.jsonl")
275- sessionPath := filepath.Join(stateDir, "session.json")
276- // Keep the transient unix socket outside the durable state root.
277- // ZOT_HOME may live on a shared or network filesystem that supports
278- // regular state files but cannot host unix socket nodes.
279- inboxPath, err := inboxSocketPath(f.cfg.Root, id)
280- if err != nil {
281- return nil, fmt.Errorf("swarm inbox path: %w", err)
282- }
283-
284- a := &Agent{
285- ID: id,
286- Task: task,
287- Dir: dir,
288- Started: f.cfg.Now(),
289- Model: strings.TrimSpace(req.Model),
290- Provider: strings.TrimSpace(req.Provider),
291- Reasoning: strings.TrimSpace(req.Reasoning),
292- FastMode: fastMode,
293- Subagent: strings.TrimSpace(req.Subagent),
294- SessionID: sessionID,
295- InboxPath: inboxPath,
296- EventLogPath: logPath,
297- SessionPath: sessionPath,
298- inbox: NewInbox(inboxPath),
299- status: StatusPending,
300- activity: "queued",
301- done: make(chan struct{}),
302- }
303- a.ctx, a.cancel = context.WithCancel(ctx)
304- a.runner = f.cfg.NewRunner(a)
305-
306- f.mu.Lock()
307- f.agents[id] = a
308- f.order = append(f.order, id)
309- f.mu.Unlock()
310-
311- // Persist the agent's identity so a later `zot` invocation can
312- // reload it from disk via Swarm.Reload. Best-effort: if the disk
313- // is read-only we still let the runner start, the user just won't
314- // see this agent on the next launch.
315- _ = writeAgentMeta(stateDir, a)
316-
317- go f.run(a)
318- return a, nil
319-}
320-
321-// SendInput delivers msg to the agent's inbox. msg should already
322-// include the leading kind ("user " + text, "cancel", "shutdown").
323-// Returns an error wrapping ErrNotReady if the child hasn't opened
324-// its listener yet; the caller can retry or surface the wait.
325-func (f *Swarm) SendInput(id, msg string) error {
326- a := f.Get(id)
327- if a == nil {
328- return fmt.Errorf("no such agent %q", id)
329- }
330- if a.inbox == nil {
331- return fmt.Errorf("agent %s has no inbox", a.ID)
332- }
333- return a.inbox.SendInput(msg)
334-}
335-
336-// SendUserTurn is sugar for the common "send the next user turn"
337-// case. It quotes nothing and forwards verbatim; callers are
338-// expected to have already trimmed and expanded the text.
339-func (f *Swarm) SendUserTurn(id, text string) error {
340- return f.SendInput(id, "user "+text)
341-}
342-
343-func (f *Swarm) run(a *Agent) {
344- a.setStatus(StatusRunning)
345- a.setActivity("starting")
346- err := a.runner.Run(a.ctx, agentSink{a: a})
347- a.mu.Lock()
348- a.finished = f.cfg.Now()
349- switch {
350- case a.status == StatusKilled:
351- // Already finalised by Stop.
352- case errors.Is(err, context.Canceled):
353- a.status = StatusKilled
354- a.activity = "cancelled"
355- case err != nil:
356- a.status = StatusFailed
357- a.activity = "error: " + truncate(err.Error(), 120)
358- a.lastErr = err
359- default:
360- a.status = StatusDone
361- a.activity = "done"
362- }
363- a.mu.Unlock()
364- close(a.done)
365-}
366-
367-// List returns a snapshot of every agent in creation order. The
368-// returned slice is a copy; callers may iterate without holding the
369-// swarm lock. Agent fields are read under their own mutex during
370-// formatting in Snapshot.
371-func (f *Swarm) List() []*Agent {
372- f.mu.Lock()
373- defer f.mu.Unlock()
374- out := make([]*Agent, 0, len(f.order))
375- for _, id := range f.order {
376- out = append(out, f.agents[id])
377- }
378- return out
379-}
380-
381-// Get returns the agent with the given (possibly truncated) id, or
382-// nil. Matching is prefix-based so the user can type the first few
383-// characters of a long id.
384-func (f *Swarm) Get(id string) *Agent {
385- f.mu.Lock()
386- defer f.mu.Unlock()
387- if a, ok := f.agents[id]; ok {
388- return a
389- }
390- // Prefix match.
391- var hits []*Agent
392- for k, a := range f.agents {
393- if strings.HasPrefix(k, id) {
394- hits = append(hits, a)
395- }
396- }
397- if len(hits) == 1 {
398- return hits[0]
399- }
400- return nil
401-}
402-
403-// Stop cancels the agent's context. The Runner should observe the
404-// cancellation and return promptly; the goroutine then finalises the
405-// agent in StatusKilled. Also closes the supervisor-side inbox handle
406-// so any pending SendInput retries fail fast instead of dialing a
407-// socket about to be unlinked.
408-//
409-// Stop is a no-op for any agent that's not in a live runnable state
410-// — Done / Failed / Killed (already finalised) and Detached (no
411-// in-process runner; reloaded from disk). Calling Stop on a detached
412-// agent must not crash: buildDetachedAgent doesn't allocate a
413-// context/cancel pair because there's nothing to cancel.
414-func (f *Swarm) Stop(id string) error {
415- a := f.Get(id)
416- if a == nil {
417- return fmt.Errorf("no such agent %q", id)
418- }
419- a.mu.Lock()
420- switch a.status {
421- case StatusDone, StatusFailed, StatusKilled, StatusDetached:
422- a.mu.Unlock()
423- return nil
424- }
425- a.status = StatusKilled
426- a.activity = "stopped"
427- a.mu.Unlock()
428- // Belt-and-braces guard against the cancel-less case (detached
429- // agents skip this branch above, but a future code path that
430- // builds an Agent without a runner shouldn't crash the
431- // supervisor).
432- if a.cancel != nil {
433- a.cancel()
434- }
435- if a.inbox != nil {
436- _ = a.inbox.Close()
437- }
438- return nil
439-}
440-
441-// StopAll cancels every running agent. Used on shutdown.
442-func (f *Swarm) StopAll() {
443- for _, a := range f.List() {
444- _ = f.Stop(a.ID)
445- }
446-}
447-
448-// Remove tears down the per-agent state for a terminated agent. It
449-// is an error to remove an agent that's still running; call Stop
450-// first and wait for the status to settle. Detached agents
451-// (reloaded from disk) remove cleanly because they have no live
452-// runner racing for the same files.
453-//
454-// Agents share the host's working tree, so Remove never touches
455-// any source file — it only deletes the agent's state directory
456-// under <root>/agents/<id>/.
457-func (f *Swarm) Remove(id string) error {
458- a := f.Get(id)
459- if a == nil {
460- return fmt.Errorf("no such agent %q", id)
461- }
462- a.mu.Lock()
463- st := a.status
464- a.mu.Unlock()
465- if st == StatusRunning || st == StatusPending {
466- return fmt.Errorf("agent %s still %s", a.ID, st)
467- }
468- // Best-effort cleanup of the per-agent state directory
469- // (meta.json, events.jsonl, session.json). Failing here would leave
470- // the user with no recourse,
471- // so swallow the error.
472- _ = os.RemoveAll(f.agentStateDir(a.ID))
473- f.mu.Lock()
474- delete(f.agents, a.ID)
475- for i, k := range f.order {
476- if k == a.ID {
477- f.order = append(f.order[:i], f.order[i+1:]...)
478- break
479- }
480- }
481- f.mu.Unlock()
482- return nil
483-}
484-
485-// Snapshot returns a read-only view of one agent. Safe for the TUI
486-// goroutine to call repeatedly; never blocks on the Runner.
487-type AgentSnapshot struct {
488- ID string
489- Task string
490- Dir string
491- Status Status
492- Activity string
493- Started time.Time
494- Finished time.Time
495- Err string
496- Tail string // last few transcript lines, joined with "\n"
497- Lines []string // full transcript (already capped by Agent.appendTranscript)
498- LastAssistant string // complete final assistant message, without role formatting
499-
500- // Model, Provider, and Reasoning expose the per-agent overrides set
501- // at Spawn time (empty when the agent inherits the child's default
502- // resolution). Subagent is the selected named markdown profile.
503- // The dashboard surfaces these so the user can confirm the child
504- // configuration.
505- Model string
506- Provider string
507- Reasoning string
508- FastMode bool
509- Subagent string
510-
511- // Paths to the agent's durable state. Surface them in the
512- // snapshot so the dashboard / /swarm open can read events.jsonl
513- // or resume the session without going back through the Agent.
514- InboxPath string
515- EventLogPath string
516- SessionPath string
517-}
518-
519-// Snapshot copies the live agent state into a value the caller can
520-// inspect at leisure.
521-func (a *Agent) Snapshot() AgentSnapshot {
522- a.mu.Lock()
523- defer a.mu.Unlock()
524- tail := strings.Join(lastN(a.transcript, 6), "\n")
525- lines := make([]string, len(a.transcript))
526- copy(lines, a.transcript)
527- errStr := ""
528- if a.lastErr != nil {
529- errStr = a.lastErr.Error()
530- }
531- return AgentSnapshot{
532- ID: a.ID, Task: a.Task, Dir: a.Dir,
533- Status: a.status, Activity: a.activity,
534- Started: a.Started, Finished: a.finished,
535- Err: errStr, Tail: tail, Lines: lines,
536- LastAssistant: a.lastAssistant,
537- Model: a.Model,
538- Provider: a.Provider,
539- Reasoning: a.Reasoning,
540- FastMode: a.FastMode,
541- Subagent: a.Subagent,
542- InboxPath: a.InboxPath,
543- EventLogPath: a.EventLogPath,
544- SessionPath: a.SessionPath,
545- }
546-}
547-
548-// SnapshotAll returns snapshots of every agent in creation order,
549-// scoped to the active session when one is set.
550-//
551-// Scoping rules:
552-// - activeSession == "": no filter, every agent is returned
553-// (historical behaviour; used by tests and scripted callers).
554-// - activeSession != "": include only agents whose SessionID
555-// matches activeSession OR is empty. The empty-id pass-through
556-// keeps pre-upgrade agents (their meta.json was written before
557-// session_id existed) visible from any session so the user
558-// doesn't lose access after the schema bump.
559-func (f *Swarm) SnapshotAll() []AgentSnapshot {
560- agents := f.List()
561- f.mu.Lock()
562- active := f.activeSession
563- f.mu.Unlock()
564-
565- out := make([]AgentSnapshot, 0, len(agents))
566- for _, a := range agents {
567- if active != "" && a.SessionID != "" && a.SessionID != active {
568- continue
569- }
570- out = append(out, a.Snapshot())
571- }
572- // Sort by start time for a stable, deterministic listing.
573- sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) })
574- return out
575-}
576-
577-// agentSink is the Sink the Swarm hands to each Runner.
578-type agentSink struct{ a *Agent }
579-
580-func (s agentSink) Activity(msg string) { s.a.setActivity(msg) }
581-func (s agentSink) Transcript(chunk string) {
582- if strings.HasPrefix(chunk, "stderr: ") || strings.HasPrefix(chunk, "error: ") {
583- s.a.appendTranscript(chunk)
584- return
585- }
586- s.a.appendAssistantMessage(chunk)
587-}
588-func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }
589-func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }
590-
591-func truncate(s string, n int) string {
592- if len(s) <= n {
593- return s
594- }
595- if n <= 3 {
596- return strings.Repeat(".", n)
597- }
598- return s[:n-3] + "..."
599-}
600-
601-func lastN(lines []string, n int) []string {
602- if len(lines) <= n {
603- return lines
604- }
605- return lines[len(lines)-n:]
606-}
D packages/agent/swarm_agent.go +0 -322
@@ -1,322 +0,0 @@
1-package agent
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "io"
8- "os"
9- "strings"
10- "sync"
11- "time"
12-
13- "github.com/patriceckhart/zot/packages/agent/modes"
14- "github.com/patriceckhart/zot/packages/agent/swarm"
15- "github.com/patriceckhart/zot/packages/core"
16- "github.com/patriceckhart/zot/packages/provider"
17-)
18-
19-// runSwarmAgentMode is the daemon-mode entry point used by every
20-// swarm-spawned zot subprocess. It's intentionally close in shape to
21-// runJSONMode but with two key differences:
22-//
23-// - Lifetime: the process stays alive across many user turns. The
24-// initial positional task (if any) is the first turn; subsequent
25-// turns arrive through the inbox unix socket at args.SwarmAgent.
26-//
27-// - Output: every emitted JSON line is also mirrored verbatim into
28-// events.jsonl (see ZOT_SWARM_EVENT_LOG) so a separate zot
29-// process can /swarm open this agent and replay its full history
30-// even after the parent that spawned us is long gone.
31-//
32-// The runner in packages/agent/swarm/runner.go is the only caller in
33-// production; tests use the stubchild binary under
34-// packages/agent/swarm/testdata/cmd/stubchild instead of the real model
35-// loop.
36-func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
37- if args.SwarmAgent == "" {
38- return fmt.Errorf("--swarm-agent requires a socket path")
39- }
40- if os.Getenv("ZOT_SWARM_CREDENTIAL_STDIN") == "1" {
41- var inherited swarm.Credential
42- dec := json.NewDecoder(io.LimitReader(os.Stdin, 1<<20))
43- if err := dec.Decode(&inherited); err != nil {
44- return fmt.Errorf("read inherited swarm credential: %w", err)
45- }
46- if inherited.Value == "" || inherited.Method == "" {
47- return fmt.Errorf("read inherited swarm credential: missing value or method")
48- }
49- args.inheritedCredential = inherited.Value
50- args.inheritedAuthMethod = inherited.Method
51- args.inheritedAccountID = inherited.AccountID
52- }
53-
54- r, err := Resolve(args, true)
55- if err != nil {
56- return err
57- }
58- extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
59- defer stopExt()
60-
61- ag := r.NewAgent()
62- initialAg := ag
63- defer func() {
64- closeAgentLSP(ag)
65- if ag != initialAg {
66- closeAgentLSP(initialAg)
67- }
68- }()
69- wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
70- sess, err := openOrCreateSession(args, r, ag, version)
71- if err != nil {
72- return err
73- }
74- if sess != nil {
75- var providerName, model string
76- sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
77- if err != nil {
78- return err
79- }
80- r.Provider, r.Model = providerName, model
81- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
82- defer sess.Close()
83- }
84- announceSession(extMgr, sess)
85-
86- // Open the inbox listener BEFORE emitting agent_ready so the
87- // supervisor can dial through on the very first send. The
88- // swarm supervisor's Inbox.SendInput retries dialing for a
89- // short window, but emitting ready first and then listening
90- // would still race in tight loops.
91- ln, err := swarm.Listen(args.SwarmAgent)
92- if err != nil {
93- return fmt.Errorf("swarm-agent listen: %w", err)
94- }
95- defer ln.Close()
96-
97- // Event log is owned by the supervisor's runner via stdout, but
98- // the daemon also writes a redundant copy here when the runner's
99- // pipe is closed (e.g. parent zot exited but the agent is still
100- // running headless). The env var is set by the runner; if it's
101- // empty we silently skip the second mirror.
102- var logMirror *swarm.EventLog
103- if path := os.Getenv("ZOT_SWARM_EVENT_LOG"); path != "" {
104- logMirror, _ = swarm.OpenEventLog(path)
105- }
106- if logMirror != nil {
107- defer logMirror.Close()
108- }
109-
110- em := newSwarmEmitter(os.Stdout, logMirror)
111-
112- em.emit("agent_ready", map[string]any{
113- "version": version,
114- "cwd": r.CWD,
115- "model": r.Model,
116- })
117-
118- // turnCtx is the per-turn context. We keep a per-process
119- // cancel so the "cancel" inbox message can interrupt an
120- // in-flight turn without tearing down the whole daemon.
121- var (
122- mu sync.Mutex
123- turnCtx context.Context = ctx
124- cancelFn context.CancelFunc
125- busyTurn bool
126- stopping bool
127- turnNo int
128- turns sync.WaitGroup
129- )
130-
131- runOne := func(prompt string) {
132- mu.Lock()
133- if busyTurn {
134- // Drop concurrent turns rather than queuing. The
135- // supervisor protocol assumes one outstanding turn per
136- // agent; if a user really wants to interrupt and start
137- // another, they should send "cancel" first.
138- mu.Unlock()
139- em.emit("error", map[string]any{"message": "agent busy; send 'cancel' first"})
140- return
141- }
142- busyTurn = true
143- turnNo++
144- step := turnNo
145- turnCtx, cancelFn = context.WithCancel(ctx)
146- c := turnCtx
147- mu.Unlock()
148-
149- em.emit("turn_start", map[string]any{"step": step})
150-
151- sink := func(ev core.AgentEvent) {
152- em.emit(ev.Type(), modes.EventToJSON(ev))
153- }
154-
155- start := len(ag.Messages())
156- err := ag.Prompt(c, prompt, nil, sink)
157- WriteNewTranscript(ag, sess, start)
158-
159- em.emit("turn_end", map[string]any{
160- "step": step,
161- "error": errString(err),
162- })
163-
164- mu.Lock()
165- busyTurn = false
166- cancelFn = nil
167- mu.Unlock()
168- }
169-
170- startTurn := func(prompt string) {
171- mu.Lock()
172- if stopping {
173- mu.Unlock()
174- return
175- }
176- turns.Add(1)
177- mu.Unlock()
178- go func() {
179- defer turns.Done()
180- runOne(prompt)
181- }()
182- }
183- stopAndWait := func(reason string) {
184- mu.Lock()
185- stopping = true
186- if cancelFn != nil {
187- cancelFn()
188- }
189- mu.Unlock()
190- turns.Wait()
191- em.emit("agent_stopped", map[string]any{"reason": reason})
192- }
193-
194- // Initial task: run before processing the inbox so the agent
195- // "starts working" the moment it boots, matching what users
196- // expect from `/swarm new <task>`.
197- if args.Prompt != "" {
198- startTurn(args.Prompt)
199- }
200-
201- // Inbox loop: one supervisor message at a time. We don't spawn
202- // a goroutine per turn because runOne already serialises them
203- // via the busyTurn flag; doing the dispatch on the main
204- // goroutine keeps the daemon's lifecycle easy to follow.
205- for {
206- select {
207- case <-ctx.Done():
208- stopAndWait("cancelled")
209- return ctx.Err()
210- case msg, ok := <-ln.Lines():
211- if !ok {
212- stopAndWait("inbox-closed")
213- return nil
214- }
215- switch {
216- case msg == "shutdown":
217- stopAndWait("shutdown")
218- return nil
219- case msg == "cancel":
220- mu.Lock()
221- if cancelFn != nil {
222- cancelFn()
223- }
224- mu.Unlock()
225- case strings.HasPrefix(msg, "user "):
226- prompt := strings.TrimPrefix(msg, "user ")
227- startTurn(prompt)
228- default:
229- em.emit("error", map[string]any{
230- "message": "unknown supervisor message: " + truncateForLog(msg, 200),
231- })
232- }
233- }
234- }
235-}
236-
237-// swarmEmitter serialises events to stdout and (optionally) to a
238-// durable log file. Concurrent goroutines call emit so we have to
239-// hold a mutex around the encoder.
240-type swarmEmitter struct {
241- mu sync.Mutex
242- w *os.File
243- mirror *swarm.EventLog
244-
245- // orphan flips true the first time a stdout write fails (broken
246- // pipe — the supervisor died). Until then the mirror stays
247- // dormant: the supervisor is the canonical writer to events.jsonl
248- // (it parses our stdout and Append()s each event itself). Writing
249- // from both sides used to land every event in the log twice,
250- // which showed up as a fully-duplicated transcript the next time
251- // the agent was reloaded — the exact "why is everything doubled"
252- // bug. Once orphaned the mirror takes over so the events still
253- // land on disk for the next reload.
254- orphan bool
255-}
256-
257-func newSwarmEmitter(w *os.File, mirror *swarm.EventLog) *swarmEmitter {
258- return &swarmEmitter{w: w, mirror: mirror}
259-}
260-
261-func (e *swarmEmitter) emit(typ string, data map[string]any) {
262- if data == nil {
263- data = map[string]any{}
264- }
265- data["type"] = typ
266- data["time"] = time.Now().Format(time.RFC3339Nano)
267-
268- e.mu.Lock()
269- defer e.mu.Unlock()
270-
271- if !e.orphan {
272- // Encode to a buffer first so we can detect the stdout write
273- // error — json.Encoder swallows partial writes silently, but
274- // a direct Write on *os.File returns broken-pipe immediately.
275- line, err := json.Marshal(data)
276- if err == nil {
277- line = append(line, '\n')
278- if _, werr := e.w.Write(line); werr != nil {
279- // Supervisor's stdout pipe is gone (parent zot exited
280- // but we kept running). Switch to mirror-only mode so
281- // subsequent events still get persisted; also retro-
282- // actively log this very event to the mirror so it
283- // doesn't get lost in the handoff.
284- e.orphan = true
285- }
286- }
287- }
288-
289- if e.orphan && e.mirror != nil {
290- // Drop "type" + "time" out of data into Event fields so the
291- // mirror file matches what the supervisor would have written.
292- flat := map[string]any{}
293- for k, v := range data {
294- if k == "type" || k == "time" {
295- continue
296- }
297- flat[k] = v
298- }
299- _ = e.mirror.Append(swarm.NewEvent(typ, flat))
300- }
301-}
302-
303-func errString(err error) string {
304- if err == nil {
305- return ""
306- }
307- return err.Error()
308-}
309-
310-func truncateForLog(s string, n int) string {
311- if len(s) <= n {
312- return s
313- }
314- if n <= 3 {
315- return strings.Repeat(".", n)
316- }
317- return s[:n-3] + "..."
318-}
319-
320-// _ keeps the provider import used; provider types may surface
321-// through ag.OnEvent / modes.EventToJSON in future iterations.
322-var _ provider.Content = provider.TextBlock{}
D packages/agent/swarm_agent_test.go +0 -121
@@ -1,121 +0,0 @@
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/packages/agent/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- defer stdoutFile.Close()
35-
36- // Mirror writes go to a separate events.jsonl that we can read
37- // at the end to assert how many events the mirror emitted.
38- mirrorPath := filepath.Join(t.TempDir(), "events.jsonl")
39- mirror, err := swarm.OpenEventLog(mirrorPath)
40- if err != nil {
41- t.Fatalf("open mirror: %v", err)
42- }
43- defer mirror.Close()
44-
45- em := newSwarmEmitter(stdoutFile, mirror)
46-
47- // Healthy stdout: emit three events. Mirror must stay empty.
48- em.emit("turn_start", map[string]any{"step": 1})
49- em.emit("assistant_message", map[string]any{"text": "hi"})
50- em.emit("turn_end", map[string]any{"step": 1})
51-
52- got, err := swarm.ReadEventLog(mirrorPath)
53- if err != nil {
54- t.Fatalf("read mirror: %v", err)
55- }
56- if len(got) != 0 {
57- t.Fatalf("mirror wrote %d events while supervisor was alive; want 0 (every event would otherwise double on the next reload)\n%+v",
58- len(got), got)
59- }
60-
61- // Simulate supervisor death: close stdout so the next Write
62- // returns EBADF / broken pipe. The emitter must flip into
63- // orphan mode and start writing through the mirror.
64- if err := stdoutFile.Close(); err != nil {
65- t.Fatalf("close stdout: %v", err)
66- }
67-
68- em.emit("assistant_message", map[string]any{"text": "after orphan"})
69- em.emit("turn_end", map[string]any{"step": 2})
70-
71- got, err = swarm.ReadEventLog(mirrorPath)
72- if err != nil {
73- t.Fatalf("read mirror post-orphan: %v", err)
74- }
75- if len(got) < 2 {
76- t.Fatalf("mirror failed to take over after stdout died: got %d events", len(got))
77- }
78- if got[len(got)-1].Type != "turn_end" {
79- t.Errorf("last mirrored event type = %q; want turn_end", got[len(got)-1].Type)
80- }
81-}
82-
83-// TestSwarmEmitterStdoutShapeMatchesSupervisorParser pins the
84-// wire-format contract: each emitted event lands on stdout as one
85-// JSON object per line with type+time at top level alongside the
86-// data fields. The supervisor's runner parses this exact shape.
87-func TestSwarmEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
88- // Pipe so we can read what the emitter wrote.
89- r, w, err := os.Pipe()
90- if err != nil {
91- t.Fatalf("pipe: %v", err)
92- }
93- defer r.Close()
94-
95- em := newSwarmEmitter(w, nil)
96- em.emit("turn_start", map[string]any{"step": 1})
97- _ = w.Close()
98-
99- body, err := io.ReadAll(r)
100- if err != nil {
101- t.Fatalf("read pipe: %v", err)
102- }
103- // One trailing newline => one event line.
104- lines := bytes.Split(bytes.TrimRight(body, "\n"), []byte("\n"))
105- if len(lines) != 1 {
106- t.Fatalf("expected 1 event line, got %d: %q", len(lines), body)
107- }
108- var flat map[string]any
109- if err := json.Unmarshal(lines[0], &flat); err != nil {
110- t.Fatalf("not valid json: %v\n%s", err, lines[0])
111- }
112- if flat["type"] != "turn_start" {
113- t.Errorf("type field missing or wrong: %v", flat["type"])
114- }
115- if _, ok := flat["time"].(string); !ok {
116- t.Errorf("time field missing: %v", flat["time"])
117- }
118- if flat["step"] != float64(1) {
119- t.Errorf("step field missing or wrong: %v", flat["step"])
120- }
121-}
R packages/agent/tools/swarm_spawn.go → packages/agent/tools/subagent_spawn.go +119 -60 renamed, 56% similar
@@ -5,27 +5,27 @@ import (
55 "encoding/json"
66 "fmt"
77 "strings"
8+ "time"
89
910 "github.com/patriceckhart/zot/packages/agent/subagents"
10- "github.com/patriceckhart/zot/packages/agent/swarm"
1111 "github.com/patriceckhart/zot/packages/core"
1212 "github.com/patriceckhart/zot/packages/provider"
1313 )
1414
15-// SwarmSpawnTool lets the main agent fork a background sub-agent
16-// against the host's cwd via swarm.Swarm.SpawnReq. The sub-agent runs
15+// SubagentSpawnTool lets the main agent fork a background sub-agent
16+// against the host's cwd via subagents.Supervisor.SpawnReq. The sub-agent runs
1717 // in parallel: the tool returns the agent id immediately and the main
1818 // turn continues uninterrupted. The user can monitor / chat with the
19-// spawned agent via /swarm.
19+// spawned agent via /subagents.
2020 //
21-// Gated by the auto_swarm_enabled config flag at call time so a user
21+// Gated by the auto_subagents_enabled config flag at call time so a user
2222 // can flip it off mid-session and the next call refuses cleanly
2323 // without re-registering the tool.
24-type SwarmSpawnTool struct {
25- // Swarm is the supervisor used to spawn agents. Nil means
26- // "auto-swarm not available in this mode" and the tool always
24+type SubagentSpawnTool struct {
25+ // Supervisor is the supervisor used to spawn agents. Nil means
26+ // "auto-subagents not available in this mode" and the tool always
2727 // errors.
28- Swarm *swarm.Swarm
28+ Supervisor *subagents.Supervisor
2929
3030 // Enabled reads the live config flag. Lets users toggle from
3131 // /settings without rebuilding the agent. When nil, the tool
@@ -34,7 +34,7 @@ type SwarmSpawnTool struct {
3434
3535 // DefaultModel and DefaultProvider return the host agent's resolved
3636 // model and provider. They are used when the tool call omits both
37- // fields and does not select a named profile, so auto-swarm follows
37+ // fields and does not select a named profile, so auto-subagents follows
3838 // the same auth route as the user sees in the parent session.
3939 DefaultModel func() string
4040 DefaultProvider func() string
@@ -48,19 +48,22 @@ type SwarmSpawnTool struct {
4848 // the new agent + the task it was started with. Used by the
4949 // interactive host to track agents and surface a summary back
5050 // in chat when all sub-agents finish.
51- OnSpawned func(agent *swarm.Agent, task string)
51+ OnSpawned func(agent *subagents.Agent, task string)
5252 }
5353
54-type swarmSpawnArgs struct {
54+type subagentSpawnArgs struct {
5555 Task string `json:"task"`
5656 Agent string `json:"agent,omitempty"`
5757 Model string `json:"model,omitempty"`
5858 Provider string `json:"provider,omitempty"`
5959 Reasoning string `json:"reasoning,omitempty"`
6060 Thinking string `json:"thinking,omitempty"`
61+ Isolation string `json:"isolation,omitempty"`
62+ Timeout string `json:"timeout,omitempty"`
63+ MaxTurns *int `json:"max_turns,omitempty"`
6164 }
6265
63-const swarmSpawnSchema = `{
66+const subagentSpawnSchema = `{
6467 "type": "object",
6568 "properties": {
6669 "task": {
@@ -88,31 +91,66 @@ const swarmSpawnSchema = `{
8891 "type": "string",
8992 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
9093 "description": "Alias for reasoning, accepted for compatibility with common agent profile terminology. Prefer reasoning when both are available."
94+ },
95+ "isolation": {
96+ "type": "string",
97+ "enum": ["shared", "worktree"],
98+ "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
99+ },
100+ "timeout": {
101+ "type": "string",
102+ "description": "Optional Go duration such as 20m."
103+ },
104+ "max_turns": {
105+ "type": "integer",
106+ "minimum": 1,
107+ "description": "Maximum prompt-level turns for this worker."
91108 }
92109 },
93110 "required": ["task"]
94111 }`
95112
96-func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" }
97-func (t *SwarmSpawnTool) Description() string {
98- return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile with agent and set its model/provider/reasoning. Named profiles may restrict fast mode but cannot enable it when the host setting is off. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. The sub-agent shares this working directory."
113+func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" }
114+func (t *SubagentSpawnTool) Description() string {
115+ return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately; completion arrives through the result/event path."
116+
99117 }
100-func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) }
118+func (t *SubagentSpawnTool) Schema() json.RawMessage { return json.RawMessage(subagentSpawnSchema) }
101119
102-func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
103- if t.Swarm == nil {
104- return protocolToolError("swarm_spawn: swarm supervisor not available in this mode")
120+func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
121+ prefix := t.Name()
122+ if t.Supervisor == nil {
123+ return protocolToolError(prefix + ": subagent supervisor not available in this mode")
105124 }
106125 if t.Enabled == nil || !t.Enabled() {
107- return protocolToolError("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks.")
126+ return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before delegating sub-tasks.")
108127 }
109- var a swarmSpawnArgs
128+ var a subagentSpawnArgs
110129 if err := json.Unmarshal(raw, &a); err != nil {
111130 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
112131 }
113132 task := strings.TrimSpace(a.Task)
114133 if task == "" {
115- return protocolToolError("swarm_spawn: task is required")
134+ return protocolToolError(prefix + ": task is required")
135+ }
136+
137+ workspaceMode := subagents.WorkspaceShared
138+ if value := strings.TrimSpace(a.Isolation); value != "" {
139+ workspaceMode = subagents.WorkspaceMode(value)
140+ if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
141+ return protocolToolError(prefix + ": isolation must be shared or worktree")
142+ }
143+ }
144+ var timeout time.Duration
145+ if value := strings.TrimSpace(a.Timeout); value != "" {
146+ parsed, parseErr := time.ParseDuration(value)
147+ if parseErr != nil || parsed <= 0 {
148+ return protocolToolError(prefix + ": timeout must be a positive duration")
149+ }
150+ timeout = parsed
151+ }
152+ if a.MaxTurns != nil && *a.MaxTurns < 1 {
153+ return protocolToolError(prefix + ": max_turns must be positive")
116154 }
117155
118156 agentName := strings.TrimSpace(a.Agent)
@@ -120,15 +158,15 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
120158 var fastModeOverride *bool
121159 if agentName != "" {
122160 if t.ResolveSubagent == nil {
123- return protocolToolError("swarm_spawn: named subagent profiles are unavailable")
161+ return protocolToolError(prefix + ": named subagent profiles are unavailable")
124162 }
125163 var err error
126164 profile, err = t.ResolveSubagent(agentName)
127165 if err != nil {
128- return protocolToolError("swarm_spawn: " + err.Error())
166+ return protocolToolError(prefix + ": " + err.Error())
129167 }
130168 if profile == nil {
131- return protocolToolError("swarm_spawn: unknown subagent profile " + agentName)
169+ return protocolToolError(prefix + ": unknown subagent profile " + agentName)
132170 }
133171 agentName = profile.Name
134172 fastModeOverride = profile.FastMode
@@ -136,55 +174,67 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
136174
137175 model := strings.TrimSpace(a.Model)
138176 providerID := strings.TrimSpace(a.Provider)
177+ var profileTools []string
178+ if profile != nil {
179+ profileTools = append([]string(nil), profile.Tools...)
180+ }
139181 if (model == "") != (providerID == "") {
140- return protocolToolError("swarm_spawn: omit both model/provider to inherit the host or profile, or provide both explicitly")
182+ return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
141183 }
142- if model == "" && providerID == "" && profile != nil {
143- // A fully-qualified profile model is useful metadata for the
144- // supervisor and also lets it inherit the right credential route.
145- // Bare profile model ids remain child-resolved so the configured
146- // provider is preserved.
184+ if profile != nil {
185+ // A profile may specify a qualified model, a bare model, a provider,
186+ // or neither. Fill only missing pieces so explicit spawn options win;
187+ // the parent session supplies whatever the profile leaves unspecified.
147188 profileProvider, profileModel := profile.ModelSelection()
148- if profileProvider != "" && profileModel != "" {
149- providerID, model = profileProvider, profileModel
189+ if model == "" {
190+ model = profileModel
150191 }
151- }
152- if model == "" && providerID == "" && profile == nil {
153- if t.DefaultModel != nil {
154- model = strings.TrimSpace(t.DefaultModel())
155- }
156- if t.DefaultProvider != nil {
157- providerID = strings.TrimSpace(t.DefaultProvider())
192+ if providerID == "" {
193+ providerID = profileProvider
158194 }
159195 }
196+ if model == "" && t.DefaultModel != nil {
197+ model = strings.TrimSpace(t.DefaultModel())
198+ }
199+ if providerID == "" && t.DefaultProvider != nil {
200+ providerID = strings.TrimSpace(t.DefaultProvider())
201+ }
160202
161203 reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)
162204 if err != nil {
163- return protocolToolError("swarm_spawn: " + err.Error())
205+ return protocolToolError(prefix + ": " + err.Error())
164206 }
165207 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
166208 reasoning, err = reasoningOverride(profile.Thinking, "")
167209 if err != nil {
168- return protocolToolError("swarm_spawn: profile " + profile.Name + ": " + err.Error())
210+ return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
169211 }
170212 }
171213 if reasoning == "" && t.DefaultReasoning != nil {
172214 reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
173215 if err != nil {
174- return protocolToolError("swarm_spawn: host " + err.Error())
216+ return protocolToolError(prefix + ": host " + err.Error())
175217 }
176218 }
177219
178- agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
179- Task: task,
180- Model: model,
181- Provider: providerID,
182- Reasoning: reasoning,
183- FastMode: fastModeOverride,
184- Subagent: agentName,
220+ maxTurns := 0
221+ if a.MaxTurns != nil {
222+ maxTurns = *a.MaxTurns
223+ }
224+ agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
225+ Task: task,
226+ Model: model,
227+ Provider: providerID,
228+ Reasoning: reasoning,
229+ FastMode: fastModeOverride,
230+ Subagent: agentName,
231+ Timeout: timeout,
232+ MaxTurns: maxTurns,
233+ WorkspaceMode: workspaceMode,
234+ Tools: profileTools,
185235 })
186236 if err != nil {
187- return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)
237+ return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
188238 }
189239 if t.OnSpawned != nil {
190240 t.OnSpawned(agent, task)
@@ -192,6 +242,8 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
192242
193243 var sb strings.Builder
194244 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
245+ fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState())
246+ fmt.Fprintf(&sb, "workspace: %s\n", agent.WorkspaceMode)
195247 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
196248 if agentName != "" {
197249 fmt.Fprintf(&sb, "agent: %s\n", agentName)
@@ -208,18 +260,25 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
208260 if agent.FastMode {
209261 sb.WriteString("fast mode: enabled\n")
210262 }
211- sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ")
263+ sb.WriteString("\nThe sub-agent is running in the background. Use /subagents in the TUI to monitor it. ")
212264 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
213265 return core.ToolResult{
214266 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
215267 Details: map[string]any{
216- "agent_id": agent.ID,
217- "task": task,
218- "agent": agentName,
219- "model": model,
220- "provider": providerID,
221- "reasoning": reasoning,
222- "fast_mode": agent.FastMode,
268+ "agent_id": agent.ID,
269+ "task": task,
270+ "agent": agentName,
271+ "model": model,
272+ "provider": providerID,
273+ "reasoning": reasoning,
274+ "fast_mode": agent.FastMode,
275+ "isolation": string(workspaceMode),
276+ "timeout": agent.Timeout.String(),
277+ "max_turns": agent.MaxTurns,
278+ "state": agent.Status(),
279+ "process_state": string(agent.ProcessState()),
280+ "turn_state": string(agent.TurnState()),
281+ "result_ref": subagents.ResultRef(agent.ID),
223282 },
224283 }, nil
225284 }
R packages/agent/tools/swarm_spawn_test.go → packages/agent/tools/subagent_spawn_test.go +101 -39 renamed, 60% similar
@@ -6,31 +6,33 @@ import (
66 "path/filepath"
77 "strings"
88 "testing"
9+ "time"
910
1011 "github.com/patriceckhart/zot/packages/agent/subagents"
11- "github.com/patriceckhart/zot/packages/agent/swarm"
1212 "github.com/patriceckhart/zot/packages/provider"
1313 )
1414
15-type noopSwarmRunner struct{}
15+type noopSupervisorRunner struct{}
1616
17-func (noopSwarmRunner) Run(context.Context, swarm.Sink) error { return nil }
17+func (noopSupervisorRunner) Run(context.Context, subagents.Sink) error { return nil }
1818
19-func newTestSwarm(t *testing.T) *swarm.Swarm {
19+func newTestSupervisor(t *testing.T) *subagents.Supervisor {
2020 t.Helper()
2121 root := t.TempDir()
22- return swarm.New(swarm.Config{
23- Root: filepath.Join(root, "swarm"),
22+ manager := subagents.New(subagents.Config{
23+ Root: filepath.Join(root, "subagents"),
2424 RepoRoot: root,
25- NewRunner: func(*swarm.Agent) swarm.Runner {
26- return noopSwarmRunner{}
25+ NewRunner: func(*subagents.Agent) subagents.Runner {
26+ return noopSupervisorRunner{}
2727 },
2828 })
29+ t.Cleanup(manager.StopAll)
30+ return manager
2931 }
3032
31-func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
32- tool := &SwarmSpawnTool{
33- Swarm: newTestSwarm(t),
33+func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
34+ tool := &SubagentSpawnTool{
35+ Supervisor: newTestSupervisor(t),
3436 Enabled: func() bool { return true },
3537 DefaultModel: func() string { return "gpt-5" },
3638 DefaultProvider: func() string { return "openai-codex" },
@@ -62,19 +64,78 @@ func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
6264 t.Fatalf("result text missing inherited model/provider:\n%s", text)
6365 }
6466
65- agents := tool.Swarm.List()
67+ agents := tool.Supervisor.List()
6668 if len(agents) != 1 {
6769 t.Fatalf("spawned agents = %d, want 1", len(agents))
6870 }
6971 if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" {
7072 t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning)
7173 }
74+ if agents[0].MaxTurns != 3 {
75+ t.Fatalf("omitted max_turns = %d, want default 3", agents[0].MaxTurns)
76+ }
77+}
78+
79+func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) {
80+ root := t.TempDir()
81+ manager := subagents.New(subagents.Config{
82+ Root: filepath.Join(root, "subagents"),
83+ RepoRoot: root,
84+ Policy: subagents.SubagentPolicy{
85+ DefaultTimeout: 37 * time.Minute,
86+ MaxTurns: 7,
87+ },
88+ NewRunner: func(*subagents.Agent) subagents.Runner {
89+ return noopSupervisorRunner{}
90+ },
91+ })
92+ t.Cleanup(manager.StopAll)
93+ tool := &SubagentSpawnTool{
94+ Supervisor: manager,
95+ Enabled: func() bool { return true },
96+ }
97+
98+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ if res.IsError {
103+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
104+ }
105+ details, ok := res.Details.(map[string]any)
106+ if !ok {
107+ t.Fatalf("details type = %T, want map[string]any", res.Details)
108+ }
109+ if got := details["timeout"]; got != (37 * time.Minute).String() {
110+ t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String())
111+ }
112+ if got := details["max_turns"]; got != 7 {
113+ t.Fatalf("max_turns detail = %v, want 7", got)
114+ }
115+}
116+
117+func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) {
118+ tool := &SubagentSpawnTool{
119+ Supervisor: newTestSupervisor(t),
120+ Enabled: func() bool { return true },
121+ }
122+
123+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","max_turns":0}`), nil)
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ if !res.IsError || !strings.Contains(textResult(res.Content), "max_turns must be positive") {
128+ t.Fatalf("result = %#v, want max_turns validation error", res)
129+ }
130+ if got := len(tool.Supervisor.List()); got != 0 {
131+ t.Fatalf("spawned agents = %d, want 0", got)
132+ }
72133 }
73134
74-func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) {
75- tool := &SwarmSpawnTool{
76- Swarm: newTestSwarm(t),
77- Enabled: func() bool { return true },
135+func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) {
136+ tool := &SubagentSpawnTool{
137+ Supervisor: newTestSupervisor(t),
138+ Enabled: func() bool { return true },
78139 ResolveSubagent: func(name string) (*subagents.Profile, error) {
79140 if name != "reviewer" {
80141 return nil, nil
@@ -100,7 +161,7 @@ func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) {
100161 t.Fatalf("result text missing %q:\n%s", want, text)
101162 }
102163 }
103- agents := tool.Swarm.List()
164+ agents := tool.Supervisor.List()
104165 if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" {
105166 t.Fatalf("agent profile/reasoning = %#v", agents)
106167 }
@@ -109,20 +170,21 @@ func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) {
109170 }
110171 }
111172
112-func TestSwarmSpawnAppliesProfileFastModeRestriction(t *testing.T) {
173+func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) {
113174 root := t.TempDir()
114175 profileFastMode := false
115- f := swarm.New(swarm.Config{
116- Root: filepath.Join(root, "swarm"),
176+ f := subagents.New(subagents.Config{
177+ Root: filepath.Join(root, "subagents"),
117178 RepoRoot: root,
118179 FastMode: true,
119- NewRunner: func(*swarm.Agent) swarm.Runner {
120- return noopSwarmRunner{}
180+ NewRunner: func(*subagents.Agent) subagents.Runner {
181+ return noopSupervisorRunner{}
121182 },
122183 })
123- tool := &SwarmSpawnTool{
124- Swarm: f,
125- Enabled: func() bool { return true },
184+ t.Cleanup(f.StopAll)
185+ tool := &SubagentSpawnTool{
186+ Supervisor: f,
187+ Enabled: func() bool { return true },
126188 ResolveSubagent: func(name string) (*subagents.Profile, error) {
127189 if name != "reviewer" {
128190 return nil, nil
@@ -147,10 +209,10 @@ func TestSwarmSpawnAppliesProfileFastModeRestriction(t *testing.T) {
147209 }
148210 }
149211
150-func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
151- tool := &SwarmSpawnTool{
152- Swarm: newTestSwarm(t),
153- Enabled: func() bool { return true },
212+func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
213+ tool := &SubagentSpawnTool{
214+ Supervisor: newTestSupervisor(t),
215+ Enabled: func() bool { return true },
154216 ResolveSubagent: func(name string) (*subagents.Profile, error) {
155217 if name != "reviewer" {
156218 return nil, nil
@@ -170,16 +232,16 @@ func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
170232 if res.IsError {
171233 t.Fatalf("unexpected tool error: %s", textResult(res.Content))
172234 }
173- agents := tool.Swarm.List()
235+ agents := tool.Supervisor.List()
174236 if len(agents) != 1 || agents[0].Reasoning != "low" {
175237 t.Fatalf("agent reasoning = %#v, want low", agents)
176238 }
177239 }
178240
179-func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) {
180- tool := &SwarmSpawnTool{
181- Swarm: newTestSwarm(t),
182- Enabled: func() bool { return true },
241+func TestSubagentSpawnRejectsUnknownProfile(t *testing.T) {
242+ tool := &SubagentSpawnTool{
243+ Supervisor: newTestSupervisor(t),
244+ Enabled: func() bool { return true },
183245 ResolveSubagent: func(string) (*subagents.Profile, error) {
184246 return nil, nil
185247 },
@@ -191,14 +253,14 @@ func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) {
191253 if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") {
192254 t.Fatalf("result = %#v, want unknown profile error", res)
193255 }
194- if got := len(tool.Swarm.List()); got != 0 {
256+ if got := len(tool.Supervisor.List()); got != 0 {
195257 t.Fatalf("spawned agents = %d, want 0", got)
196258 }
197259 }
198260
199-func TestSwarmSpawnRejectsPartialModelProviderOverride(t *testing.T) {
200- tool := &SwarmSpawnTool{
201- Swarm: newTestSwarm(t),
261+func TestSubagentSpawnRejectsPartialModelProviderOverride(t *testing.T) {
262+ tool := &SubagentSpawnTool{
263+ Supervisor: newTestSupervisor(t),
202264 Enabled: func() bool { return true },
203265 DefaultModel: func() string { return "gpt-5" },
204266 DefaultProvider: func() string { return "openai-codex" },
@@ -214,7 +276,7 @@ func TestSwarmSpawnRejectsPartialModelProviderOverride(t *testing.T) {
214276 if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") {
215277 t.Fatalf("error text = %q", got)
216278 }
217- if got := len(tool.Swarm.List()); got != 0 {
279+ if got := len(tool.Supervisor.List()); got != 0 {
218280 t.Fatalf("spawned agents = %d, want 0", got)
219281 }
220282 }
M packages/core/agent.go +44 -9
@@ -16,9 +16,13 @@ import (
1616 // Agent is a stateful conversation bound to a provider client, a model,
1717 // and a set of tools.
1818 type Agent struct {
19- Client provider.Client
20- Model string
21- System string
19+ Client provider.Client
20+ Model string
21+ // System is mutex-protected; use SetSystemPrompt to write it and
22+ // PromptConfig to read it.
23+ System string
24+ // Tools is mutex-protected; use SetTools to write it and PromptConfig
25+ // or ToolsSnapshot to read it.
2226 Tools Registry
2327 MaxSteps int
2428 Reasoning string
@@ -264,6 +268,36 @@ func (a *Agent) SetTools(reg Registry) {
264268 a.mu.Unlock()
265269 }
266270
271+// SetSystemPrompt replaces the system prompt used for subsequent turns.
272+func (a *Agent) SetSystemPrompt(system string) {
273+ a.mu.Lock()
274+ a.System = system
275+ a.mu.Unlock()
276+}
277+
278+// PromptConfig returns a consistent snapshot of the system prompt and tools
279+// used to construct a provider request.
280+func (a *Agent) PromptConfig() (string, Registry) {
281+ a.mu.Lock()
282+ defer a.mu.Unlock()
283+ tools := make(Registry, len(a.Tools))
284+ for name, tool := range a.Tools {
285+ tools[name] = tool
286+ }
287+ return a.System, tools
288+}
289+
290+// ToolsSnapshot returns a copy of the current tool registry.
291+func (a *Agent) ToolsSnapshot() Registry {
292+ a.mu.Lock()
293+ defer a.mu.Unlock()
294+ tools := make(Registry, len(a.Tools))
295+ for name, tool := range a.Tools {
296+ tools[name] = tool
297+ }
298+ return tools
299+}
300+
267301 // SetMessages replaces the transcript (used when resuming a session).
268302 func (a *Agent) SetMessages(msgs []provider.Message) {
269303 a.mu.Lock()
@@ -594,7 +628,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
594628 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {
595629 return provider.StopError, provider.Message{}, err
596630 }
597- system := a.System
631+ system, tools := a.PromptConfig()
598632 if contextText := boundedTurnContext(turnContext); contextText != "" {
599633 if system != "" {
600634 system += "\n\n"
@@ -613,7 +647,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
613647 // with "tool_use ids were found without tool_result blocks". The
614648 // repair is pure and a no-op on already-valid transcripts.
615649 Messages: repairToolUseResultPairs(a.Messages()),
616- Tools: a.Tools.Specs(),
650+ Tools: tools.Specs(),
617651 Reasoning: a.Reasoning,
618652 FastMode: fastMode,
619653 MaxTokens: a.MaxTokens,
@@ -723,13 +757,14 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
723757 var results []provider.Content
724758 var addedTools []string
725759 hadError := false
760+ tools := a.ToolsSnapshot()
726761
727762 for _, c := range msg.Content {
728763 tc, ok := c.(provider.ToolCallBlock)
729764 if !ok {
730765 continue
731766 }
732- res := a.runOneTool(ctx, tc, sink)
767+ res := a.runOneTool(ctx, tc, tools, sink)
733768 if res.IsError {
734769 hadError = true
735770 }
@@ -739,7 +774,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
739774 IsError: res.IsError,
740775 })
741776 for _, name := range res.ActivateTools {
742- if _, err := a.Tools.Get(name); err == nil && !containsString(addedTools, name) {
777+ if _, err := tools.Get(name); err == nil && !containsString(addedTools, name) {
743778 addedTools = append(addedTools, name)
744779 }
745780 }
@@ -757,8 +792,8 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
757792 }, hadError
758793 }
759794
760-func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult {
761- tool, err := a.Tools.Get(tc.Name)
795+func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) ToolResult {
796+ tool, err := tools.Get(tc.Name)
762797 if err != nil {
763798 return ToolResult{
764799 Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
M packages/core/core_test.go +2 -2
@@ -116,11 +116,11 @@ func TestSessionRoundTripPreservesThoughtSignatures(t *testing.T) {
116116 }
117117 }
118118
119-// TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's
119+// TestNewSessionAtPathCreatesAtExplicitPath proves the subagent worker's
120120 // session-persistence fallback works: NewSessionAtPath creates the
121121 // file (and parent dirs) at the exact path the caller chose,
122122 // independent of SessionsDir(root, cwd). Without this helper, the
123-// swarm child's --session <path> with a non-existent path would
123+// subagent worker's --session <path> with a non-existent path would
124124 // fall through to NewSession and write the session under a
125125 // different (autogenerated) name, leaving the supervisor-chosen
126126 // path empty.
M packages/core/intercept_test.go +6 -3
@@ -39,11 +39,12 @@ func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
3939 }
4040
4141 ctx := context.Background()
42+ tools := a.ToolsSnapshot()
4243 res := a.runOneTool(ctx, provider.ToolCallBlock{
4344 ID: "T1",
4445 Name: "echo",
4546 Arguments: json.RawMessage(`{"command":"ls"}`),
46- }, func(AgentEvent) {})
47+ }, tools, func(AgentEvent) {})
4748 if res.IsError {
4849 t.Fatalf("unexpected error result: %v", res.Content)
4950 }
@@ -65,12 +66,13 @@ func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
6566 }
6667
6768 ctx := context.Background()
69+ tools := a.ToolsSnapshot()
6870 orig := json.RawMessage(`{"command":"ls"}`)
6971 a.runOneTool(ctx, provider.ToolCallBlock{
7072 ID: "T1",
7173 Name: "echo",
7274 Arguments: orig,
73- }, func(AgentEvent) {})
75+ }, tools, func(AgentEvent) {})
7476 if string(rec.lastArgs) != string(orig) {
7577 t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig))
7678 }
@@ -88,11 +90,12 @@ func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
8890 }
8991
9092 ctx := context.Background()
93+ tools := a.ToolsSnapshot()
9194 res := a.runOneTool(ctx, provider.ToolCallBlock{
9295 ID: "T1",
9396 Name: "echo",
9497 Arguments: json.RawMessage(`{"command":"ls"}`),
95- }, func(AgentEvent) {})
98+ }, tools, func(AgentEvent) {})
9699 if !res.IsError {
97100 t.Fatal("want error result, got success")
98101 }
M packages/core/session.go +1 -1
@@ -153,7 +153,7 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error
153153 }
154154
155155 // NewSessionAtPath creates a session at an explicit file path. Used
156-// by callers (notably the swarm-agent child) that need the session
156+// by callers (notably the subagent worker) that need the session
157157 // file to live at a path chosen by their parent rather than under
158158 // SessionsDir. Returns an error if the file already exists — use
159159 // OpenSession for that case.
M packages/tui/render.go +1 -1
@@ -476,7 +476,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
476476 // alternative — always writing SeqHideCursor + cursor-position +
477477 // SeqShowCursor — resets the terminal's cursor blink timer on
478478 // every tick. At our 120ms animation cadence that means the
479- // caret in an idle dialog editor (e.g. an open swarm transcript
479+ // caret in an idle dialog editor (e.g. an open subagent transcript
480480 // for an agent that's currently idle) appears as a solid block
481481 // that never blinks, because we keep "showing" it before the
482482 // terminal can blink it off. Bailing out here lets the OS run
M packages/tui/render_test.go +1 -1
@@ -14,7 +14,7 @@ import (
1414 // used to always emit SeqHideCursor + cursor-position +
1515 // SeqShowCursor, which resets the terminal's blink timer. Faster
1616 // than the OS blink interval, so an idle dialog editor (e.g. a
17-// re-opened swarm transcript whose agent isn't producing output)
17+// re-opened subagent transcript whose agent isn't producing output)
1818 // rendered the caret as a solid non-blinking block.
1919 //
2020 // With the no-op fast path the renderer leaves the screen alone
M packages/tui/statusbar_test.go +18 -0
@@ -57,6 +57,24 @@ func TestStatusBarNoCWD(t *testing.T) {
5757 }
5858 }
5959
60+func TestStatusBarShowsFastMode(t *testing.T) {
61+ lines := StatusBar(StatusBarParams{
62+ Theme: Dark,
63+ Provider: "openai-codex",
64+ Model: "gpt-5.6-luna",
65+ FastMode: true,
66+ CWD: "/tmp/x",
67+ Cols: 200,
68+ })
69+ if len(lines) != 2 {
70+ t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
71+ }
72+ plain := stripANSI(lines[0])
73+ if !strings.Contains(plain, "fast mode") {
74+ t.Fatalf("fast mode should be visible in status bar: %q", plain)
75+ }
76+}
77+
6078 func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {
6179 lines := StatusBar(StatusBarParams{
6280 Theme: Dark,
M packages/tui/view.go +34 -10
@@ -2408,6 +2408,7 @@ type StatusBarParams struct {
24082408 Provider string
24092409 Model string
24102410 Reasoning string // "" means thinking off
2411+ FastMode bool // show the provider's opt-in fast tier when enabled
24112412 Busy bool
24122413 BusyPrefix string // spinner + funny line when busy
24132414 CWD string
@@ -2509,8 +2510,15 @@ func StatusBar(p StatusBarParams) []string {
25092510 if reasoning != "" {
25102511 reasoningText = "reasoning: " + reasoning
25112512 }
2513+ fastText := ""
2514+ if p.FastMode {
2515+ fastText = "fast mode"
2516+ }
25122517 statsText := strings.Join(stats, " ")
2513- middleParts := make([]string, 0, 2)
2518+ middleParts := make([]string, 0, 3)
2519+ if fastText != "" {
2520+ middleParts = append(middleParts, fastText)
2521+ }
25142522 if reasoningText != "" {
25152523 middleParts = append(middleParts, reasoningText)
25162524 }
@@ -2570,7 +2578,7 @@ func StatusBar(p StatusBarParams) []string {
25702578 modelLine := pad + th.FG256(th.Muted, left)
25712579 lines := []string{busyLine}
25722580 if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {
2573- lines = appendWrappedStatusLines(lines, th, pad, left, reasoningText, statsText, p.Cols)
2581+ lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)
25742582 } else {
25752583 var infoBuilder strings.Builder
25762584 infoBuilder.WriteString(modelLine)
@@ -2592,7 +2600,7 @@ func StatusBar(p StatusBarParams) []string {
25922600 // into an awkward position on small widths.
25932601 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
25942602 var lines []string
2595- lines = appendWrappedStatusLines(lines, th, pad, left, reasoningText, statsText, p.Cols)
2603+ lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols)
25962604 if cwd != "" {
25972605 lines = append(lines, pad+th.FG256(th.Muted, cwd))
25982606 }
@@ -2609,9 +2617,17 @@ func StatusBar(p StatusBarParams) []string {
26092617 return []string{primary, cwdRendered}
26102618 }
26112619
2612-func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, reasoningText, statsText string, cols int) []string {
2620+func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText, statsText string, cols int) []string {
26132621 modelLine := pad + th.FG256(th.Muted, modelText)
2614- if reasoningText == "" {
2622+ infoParts := make([]string, 0, 2)
2623+ if fastText != "" {
2624+ infoParts = append(infoParts, fastText)
2625+ }
2626+ if reasoningText != "" {
2627+ infoParts = append(infoParts, reasoningText)
2628+ }
2629+ infoText := strings.Join(infoParts, pad)
2630+ if infoText == "" {
26152631 lines = append(lines, modelLine)
26162632 if statsText != "" {
26172633 lines = append(lines, pad+th.FG256(th.Muted, statsText))
@@ -2619,13 +2635,21 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, reasonin
26192635 return lines
26202636 }
26212637
2622- modelReasoningPlain := pad + modelText + pad + reasoningText
2623- reasoningColor := reasoningStatusColor(th, reasoningText)
2624- if cols <= 0 || visibleWidth(modelReasoningPlain) <= cols {
2625- lines = append(lines, pad+th.FG256(reasoningColor, modelText+pad+reasoningText))
2638+ modelInfoPlain := pad + modelText + pad + infoText
2639+ infoColor := th.Muted
2640+ if reasoningText != "" {
2641+ infoColor = reasoningStatusColor(th, reasoningText)
2642+ }
2643+ if cols <= 0 || visibleWidth(modelInfoPlain) <= cols {
2644+ lines = append(lines, pad+th.FG256(infoColor, modelText+pad+infoText))
26262645 } else {
26272646 lines = append(lines, modelLine)
2628- lines = append(lines, pad+th.FG256(reasoningColor, reasoningText))
2647+ if fastText != "" {
2648+ lines = append(lines, pad+th.FG256(th.Muted, fastText))
2649+ }
2650+ if reasoningText != "" {
2651+ lines = append(lines, pad+th.FG256(reasoningStatusColor(th, reasoningText), reasoningText))
2652+ }
26292653 }
26302654 if statsText != "" {
26312655 lines = append(lines, pad+th.FG256(th.Muted, statsText))

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