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:
42 | `packages/agent/extproto` | Extension wire protocol |42 | `packages/agent/extproto` | Extension wire protocol |
43 | `packages/agent/ext` | Public extension SDK |43 | `packages/agent/ext` | Public extension SDK |
44 | `packages/agent/skills` | Skill discovery and loading |44 | `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 |
46 | `packages/agent/tools` | Built-in tools, permission checks, and jail behavior |46 | `packages/agent/tools` | Built-in tools, permission checks, and jail behavior |
47 | `packages/tui` | Terminal input, layout, rendering, markdown, themes, and images |47 | `packages/tui` | Terminal input, layout, rendering, markdown, themes, and images |
48 | `packages/ignore` | Ignore-pattern matching |48 | `packages/ignore` | Ignore-pattern matching |
M CONTRIBUTING.md +1 -1
@@ -46,7 +46,7 @@ The major ownership boundaries are:
46 - `packages/agent/modes`: interactive and non-interactive user modes46 - `packages/agent/modes`: interactive and non-interactive user modes
47 - `packages/agent/extensions`, `extproto`, and `ext`: extension hosting, protocol, and SDK47 - `packages/agent/extensions`, `extproto`, and `ext`: extension hosting, protocol, and SDK
48 - `packages/agent/skills`: reusable instruction discovery48 - `packages/agent/skills`: reusable instruction discovery
49-- `packages/agent/swarm`: background-agent supervision and state49+- `packages/agent/subagents`: background-agent supervision and state
50 - `packages/agent/tools`: built-in tools, permissions, and jail checks50 - `packages/agent/tools`: built-in tools, permissions, and jail checks
51 - `packages/tui`: terminal mechanics and rendering51 - `packages/tui`: terminal mechanics and rendering
52 - `packages/ignore`: ignore matching52 - `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
290 | `/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. |290 | `/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. |
291 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |291 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
292 | `/btw` | Side chat with full context that doesn't add to the main thread. |292 | `/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). |
294 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |294 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
295 | `/compact` | Summarize the transcript into one message to free up context. |295 | `/compact` | Summarize the transcript into one message to free up context. |
296 | `/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`. |296 | `/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
298 | `/unjail` | Allow tools to touch paths outside again. |298 | `/unjail` | Allow tools to touch paths outside again. |
299 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |299 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
300 | `/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`. |300 | `/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. |
302 | `/clear` | Clear the chat transcript. |302 | `/clear` | Clear the chat transcript. |
303 | `/exit` | Exit zot. |303 | `/exit` | Exit zot. |
304 304
@@ -318,7 +318,7 @@ Four ops on the current session. `/session` alone opens a picker; each is also r
318 318
319 - **`/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.319 - **`/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.
320 320
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.
322 - **`/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).322 - **`/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).
323 - **`/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.323 - **`/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.
324 - **`/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.324 - **`/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
346 346
347 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.347 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.
348 348
349-### `/swarm`349+### `/subagents`
350 350
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.
352 352
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.
354 354
355-```355+```text
356-/swarm # open the dashboard356+/subagents # open the dashboard
357-/swarm new <task> # spawn an agent357+/subagents new <task> # spawn an agent (shared mode)
358-/swarm new --agent reviewer <task> # use a named markdown profile358+/subagents new --agent reviewer <task> # use a named markdown profile
359-/swarm new --model gpt-5 <task> # pin the new agent to a specific model359+/subagents new --reasoning high <task> # set child reasoning
360-/swarm new --reasoning high <task> # set the child reasoning level360+/subagents logs <id> # open one agent's transcript
361-/swarm logs <id> # jump straight into one agent's transcript361+/subagents send <id> <text> # send a follow-up turn
362-/swarm send <id> <text> # send a follow-up without opening the dashboard362+/subagents result <id> # show the structured result reference
363-/swarm resume # pick a stopped agent to bring back363+/subagents resume-session <id> # continue without replaying the task
364-/swarm resume <id> # bring a specific agent back364+/subagents restart-task <id> # intentionally run the stored task again
365-/swarm kill <id> # stop a running agent (its state stays)365+/subagents kill <id> # cancel gracefully, then force-stop
366-/swarm remove <id> # delete the agent's session and state366+/subagents remove <id> # delete durable state
367-/swarm list # alias for opening the dashboard
368 ```367 ```
369 368
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:
371 370
372 | Key | Action |371 | Key | Action |
373 |---|---|372 |---|---|
@@ -386,17 +385,17 @@ Background subagents that run alongside your main session. Each one is a separat
386 385
387 **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.386 **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.
388 387
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.
390 389
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.
392 391
393 **`/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.392 **`/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.
394 393
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`.
396 395
397 ### Named subagent profiles396 ### Named subagent profiles
398 397
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.
400 399
401 ### `/settings`400 ### `/settings`
402 401
@@ -405,7 +404,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
405 - **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.404 - **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.
406 - **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.405 - **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.
407 - **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`.406 - **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.
409 - **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.408 - **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.
410 - **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`.409 - **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`.
411 - **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.410 - **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
1068 packages/agent/modes/telegram/ telegram adapter, api client, daemon1067 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1069 packages/agent/tools/ read, write, edit, bash, lsp, sandbox1068 packages/agent/tools/ read, write, edit, bash, lsp, sandbox
1070 packages/agent/skills/ skill discovery, frontmatter parser, skill tool1069 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1071-packages/agent/subagents/ named markdown profile discovery and dispatch1070+packages/agent/subagents/ named profiles, supervisor, and background runtime
1072-packages/agent/swarm/ background subagent runtime
1073 packages/agent/sdk/ public Go SDK for embedding zot in-process (package sdk)1071 packages/agent/sdk/ public Go SDK for embedding zot in-process (package sdk)
1074 packages/agent/ext/ public Go SDK for writing extensions (package ext)1072 packages/agent/ext/ public Go SDK for writing extensions (package ext)
1075 ```1073 ```
M docs/lsp.md +1 -1
@@ -120,7 +120,7 @@ diagnostics allows a later recurrence to be surfaced again.
120 120
121 - **lsp in main session** (`lsp_enabled`) controls the main agent and applies121 - **lsp in main session** (`lsp_enabled`) controls the main agent and applies
122 immediately to the live tool registry.122 immediately to the live tool registry.
123-- **lsp in sub-agents** (`subagent_lsp_enabled`) controls newly spawned swarm123+- **lsp in sub-agents** (`subagent_lsp_enabled`) controls newly spawned subagent
124 child processes.124 child processes.
125 125
126 The sub-agent setting does not grant access to a child whose profile or126 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`.
123 123
124 Fast mode is currently limited to those OpenAI provider IDs. Enabling it while124 Fast mode is currently limited to those OpenAI provider IDs. Enabling it while
125 using another provider returns an error instead of silently changing that125 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.
127 127
128 Example:128 Example:
129 129
M docs/subagents.md +27 -6
@@ -39,7 +39,7 @@ Supported metadata:
39 39
40 | Field | Meaning |40 | Field | Meaning |
41 |---|---|41 |---|---|
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. |
43 | `description` | Short description shown to the main agent. |43 | `description` | Short description shown to the main agent. |
44 | `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. |44 | `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. |
45 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |45 | `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:
50 | `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. |50 | `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. |
51 | `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. |51 | `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. |
52 52
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.
54 54
55 ## Selecting a profile55 ## Selecting a profile
56 56
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`:
58 58
59 ```json59 ```json
60 {60 {
@@ -80,8 +80,29 @@ A per-spawn reasoning override is also accepted:
80 The interactive command also supports the same selection explicitly:80 The interactive command also supports the same selection explicitly:
81 81
82 ```text82 ```text
83-/swarm new --agent reviewer Review the authentication package83+/subagents new --agent reviewer Review the authentication package
84-/swarm new --agent implementer --reasoning high Implement the parser change84+/subagents new --agent implementer --reasoning high Implement the parser change
85 ```85 ```
86 86
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 (
20 ModeStream Mode = "stream"20 ModeStream Mode = "stream"
21 ModeJSON Mode = "json"21 ModeJSON Mode = "json"
22 ModeRPC Mode = "rpc"22 ModeRPC Mode = "rpc"
23- // ModeSwarmAgent is the long-lived, headless daemon mode used by23+ // ModeSubagentWorker is the long-lived, headless daemon mode used by
24- // swarm-spawned agents. The binary opens a unix-socket inbox at24+ // subagent-spawned agents. The binary opens a unix-socket inbox at
25- // the path provided by --swarm-agent, reads supervisor messages25+ // the path provided by --subagent-worker, reads versioned JSONL
26- // off it ("user ...", "cancel", "shutdown"), runs each user turn26+ // supervisor messages, runs each user turn against a persistent session,
27- // against a persistent session, and streams JSONL events on27+ // and streams JSONL events on
28- // stdout. See packages/agent/swarm/inbox.go for the wire protocol.28+ // stdout.
29- ModeSwarmAgent Mode = "swarm-agent"29+ ModeSubagentWorker Mode = "subagent-worker"
30 )30 )
31 31
32 // Args holds parsed command-line options.32 // Args holds parsed command-line options.
@@ -36,7 +36,7 @@ type Args struct {
36 Model string36 Model string
37 APIKey string37 APIKey string
38 38
39- // inheritedCredential is populated only by swarm-agent mode from39+ // inheritedCredential is populated only by subagent-worker mode from
40 // the supervisor's stdin. It is never accepted as a CLI argument.40 // the supervisor's stdin. It is never accepted as a CLI argument.
41 inheritedCredential string41 inheritedCredential string
42 inheritedAuthMethod string42 inheritedAuthMethod string
@@ -48,11 +48,11 @@ type Args struct {
48 Reasoning string48 Reasoning string
49 Temperature *float3249 Temperature *float32
50 50
51- // FastMode is an internal swarm-child propagation flag. Normal users51+ // FastMode is an internal subagent-child propagation flag. Normal users
52 // enable fast mode through the persisted config /settings toggle.52 // enable fast mode through the persisted config /settings toggle.
53 FastMode bool53 FastMode bool
54 // FastModeSet distinguishes an explicit child override (including54 // FastModeSet distinguishes an explicit child override (including
55- // --no-fast-mode) from an omitted flag so persisted swarm settings55+ // --no-fast-mode) from an omitted flag so persisted subagent settings
56 // survive a parent config change.56 // survive a parent config change.
57 FastModeSet bool57 FastModeSet bool
58 58
@@ -123,13 +123,16 @@ type Args struct {
123 // mode auto-submits it once at startup before InitialInput handling.123 // mode auto-submits it once at startup before InitialInput handling.
124 StartupPre string124 StartupPre string
125 125
126- // SwarmAgent is the inbox-socket path when this process is a126+ // SubagentWorker is the inbox-socket path when this process is a
127- // swarm-spawned agent. Empty in every other mode. Set by127+ // subagent worker. Empty in every other mode. Set by either
128- // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent.128+ // --subagent-worker.
129- SwarmAgent string129+ SubagentWorker string
130 130
131- // Subagent selects a named markdown profile for a swarm child.131+ // SubagentMaxTurns limits prompt-level turns in worker mode.
132- // It is intentionally an internal child flag; the parent swarm tool132+ 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
133 // passes only the profile name and the child discovers the definition136 // passes only the profile name and the child discovers the definition
134 // locally.137 // locally.
135 Subagent string138 Subagent string
@@ -158,6 +161,12 @@ func ParseArgs(in []string) (Args, error) {
158 for i := 0; i < len(in); i++ {161 for i := 0; i < len(in); i++ {
159 arg := in[i]162 arg := in[i]
160 switch arg {163 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)
161 case "-h", "--help":170 case "-h", "--help":
162 a.Help = true171 a.Help = true
163 case "-v", "--version":172 case "-v", "--version":
@@ -289,13 +298,13 @@ func ParseArgs(in []string) (Args, error) {
289 return a, err298 return a, err
290 }299 }
291 a.CWD = v300 a.CWD = v
292- case "--swarm-agent":301+ case "--subagent-worker":
293 v, err := want(&i, arg)302 v, err := want(&i, arg)
294 if err != nil {303 if err != nil {
295 return a, err304 return a, err
296 }305 }
297- a.SwarmAgent = v306+ a.SubagentWorker = v
298- a.Mode = ModeSwarmAgent307+ a.Mode = ModeSubagentWorker
299 case "--subagent":308 case "--subagent":
300 v, err := want(&i, arg)309 v, err := want(&i, arg)
301 if err != nil {310 if err != nil {
@@ -323,6 +332,16 @@ func ParseArgs(in []string) (Args, error) {
323 return a, fmt.Errorf("--max-steps must be a positive integer")332 return a, fmt.Errorf("--max-steps must be a positive integer")
324 }333 }
325 a.MaxSteps = n334 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
326 default:345 default:
327 if strings.HasPrefix(arg, "-") && arg != "-" {346 if strings.HasPrefix(arg, "-") && arg != "-" {
328 return a, fmt.Errorf("unknown flag %q", arg)347 return a, fmt.Errorf("unknown flag %q", arg)
M packages/agent/args_test.go +12 -2
@@ -3,11 +3,11 @@ package agent
3 import "testing"3 import "testing"
4 4
5 func TestParseArgsSubagentAndReasoning(t *testing.T) {5 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"})
7 if err != nil {7 if err != nil {
8 t.Fatal(err)8 t.Fatal(err)
9 }9 }
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" {
11 t.Fatalf("parsed args = mode=%q subagent=%q reasoning=%q prompt=%q", args.Mode, args.Subagent, args.Reasoning, args.Prompt)11 t.Fatalf("parsed args = mode=%q subagent=%q reasoning=%q prompt=%q", args.Mode, args.Subagent, args.Reasoning, args.Prompt)
12 }12 }
13 }13 }
@@ -22,6 +22,16 @@ func TestParseArgsNoLSP(t *testing.T) {
22 }22 }
23 }23 }
24 24
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+
25 func TestParseArgsTemperatureAllowsZero(t *testing.T) {35 func TestParseArgsTemperatureAllowsZero(t *testing.T) {
26 args, err := ParseArgs([]string{"--temperature", "0"})36 args, err := ParseArgs([]string{"--temperature", "0"})
27 if err != nil {37 if err != nil {
M packages/agent/build.go +18 -3
@@ -691,7 +691,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
691 if args.PermissionSet != nil {691 if args.PermissionSet != nil {
692 sandbox.SetPermissions(args.PermissionSet)692 sandbox.SetPermissions(args.PermissionSet)
693 }693 }
694- subagentSession := args.Mode == ModeSwarmAgent || strings.TrimSpace(args.Subagent) != ""694+ subagentSession := args.Mode == ModeSubagentWorker || strings.TrimSpace(args.Subagent) != ""
695 lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(subagentSession)695 lspEnabled := !args.NoLSP && cfg.LSPEnabledFor(subagentSession)
696 reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(subagentSession), cfg.LSPDiagnosticsOnEditEnabled(subagentSession))696 reg := buildToolRegistry(args, args.CWD, sandbox, lspEnabled, cfg.LSPDiagnosticsOnWriteEnabled(subagentSession), cfg.LSPDiagnosticsOnEditEnabled(subagentSession))
697 697
@@ -737,13 +737,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
737 append_ = append(append_, skillAddendum)737 append_ = append(append_, skillAddendum)
738 }738 }
739 interactiveMode := args.Mode == "" || args.Mode == ModeInteractive739 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 {
741 homeDir, _ := os.UserHomeDir()741 homeDir, _ := os.UserHomeDir()
742 profiles, _ := subagents.Discover(args.CWD, homeDir)742 profiles, _ := subagents.Discover(args.CWD, homeDir)
743 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {743 if subagentsAddendum := subagents.SystemPromptAddendum(profiles); subagentsAddendum != "" {
744 append_ = append(append_, subagentsAddendum)744 append_ = append(append_, subagentsAddendum)
745 }745 }
746- append_ = append(append_, AutoSwarmSystemAddendum)746+ append_ = append(append_, AutoSubagentsSystemAddendum)
747 }747 }
748 if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {748 if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
749 append_ = append(append_, selectedProfile.SystemPrompt)749 append_ = append(append_, selectedProfile.SystemPrompt)
@@ -1188,6 +1188,21 @@ func lspManagerNeeded(args Args, diagnosticsOnWrite, diagnosticsOnEdit bool) boo
1188 return false1188 return false
1189 }1189 }
1190 1190
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+
1191 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1206 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1192 order := []string{"read", "write", "edit", "bash", "lsp"}1207 order := []string{"read", "write", "edit", "bash", "lsp"}
1193 var out []ToolSummary1208 var out []ToolSummary
M packages/agent/build_test.go +3 -3
@@ -156,7 +156,7 @@ func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
156 }156 }
157 }157 }
158 158
159-func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) {159+func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) {
160 root := t.TempDir()160 root := t.TempDir()
161 home := filepath.Join(root, "home")161 home := filepath.Join(root, "home")
162 zotHome := filepath.Join(root, "zot-home")162 zotHome := filepath.Join(root, "zot-home")
@@ -171,7 +171,7 @@ func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) {
171 t.Setenv("ZOT_HOME", zotHome)171 t.Setenv("ZOT_HOME", zotHome)
172 t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))172 t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
173 enabled := true173 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 {
175 t.Fatal(err)175 t.Fatal(err)
176 }176 }
177 profile := `---177 profile := `---
@@ -510,7 +510,7 @@ func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
510 }510 }
511 }511 }
512 512
513-func TestResolveUsesInheritedSwarmCredential(t *testing.T) {513+func TestResolveUsesInheritedSupervisorCredential(t *testing.T) {
514 t.Setenv("ZOT_HOME", t.TempDir())514 t.Setenv("ZOT_HOME", t.TempDir())
515 t.Setenv("OPENAI_API_KEY", "")515 t.Setenv("OPENAI_API_KEY", "")
516 516
M packages/agent/cli.go +194 -112
@@ -21,7 +21,6 @@ import (
21 "github.com/patriceckhart/zot/packages/agent/modes"21 "github.com/patriceckhart/zot/packages/agent/modes"
22 "github.com/patriceckhart/zot/packages/agent/skills"22 "github.com/patriceckhart/zot/packages/agent/skills"
23 "github.com/patriceckhart/zot/packages/agent/subagents"23 "github.com/patriceckhart/zot/packages/agent/subagents"
24- "github.com/patriceckhart/zot/packages/agent/swarm"
25 "github.com/patriceckhart/zot/packages/agent/tools"24 "github.com/patriceckhart/zot/packages/agent/tools"
26 "github.com/patriceckhart/zot/packages/core"25 "github.com/patriceckhart/zot/packages/core"
27 "github.com/patriceckhart/zot/packages/provider"26 "github.com/patriceckhart/zot/packages/provider"
@@ -737,8 +736,8 @@ func runWithArgs(args Args, version string) error {
737 return runJSONMode(ctx, args, version)736 return runJSONMode(ctx, args, version)
738 case ModeRPC:737 case ModeRPC:
739 return runRPCMode(ctx, args, version)738 return runRPCMode(ctx, args, version)
740- case ModeSwarmAgent:739+ case ModeSubagentWorker:
741- return runSwarmAgentMode(ctx, args, version)740+ return runSubagentWorkerMode(ctx, args, version)
742 default:741 default:
743 return runInteractive(ctx, args, version)742 return runInteractive(ctx, args, version)
744 }743 }
@@ -1142,7 +1141,7 @@ func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.S
1142 // skills and currently loaded extension tools) and updates the live1141 // skills and currently loaded extension tools) and updates the live
1143 // agent's registry and system prompt. Used after /reload-ext and after1142 // agent's registry and system prompt. Used after /reload-ext and after
1144 // zotfile entry.pre installs new skills or extensions.1143 // 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).
1146 func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) {1145 func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) {
1147 if ag == nil {1146 if ag == nil {
1148 return1147 return
@@ -1183,7 +1182,37 @@ func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *exte
1183 1182
1184 // ---- interactive mode: opens the TUI even without credentials ----1183 // ---- interactive mode: opens the TUI even without credentials ----
1185 1184
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+
1186 func runInteractive(ctx context.Context, args Args, version string) error {1214 func runInteractive(ctx context.Context, args Args, version string) error {
1215+ initialCfg, _ := LoadConfig()
1187 // Resolve WITHOUT requiring credentials.1216 // Resolve WITHOUT requiring credentials.
1188 r, err := Resolve(args, false)1217 r, err := Resolve(args, false)
1189 if err != nil {1218 if err != nil {
@@ -1201,7 +1230,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1201 1230
1202 // persistMu guards the active session and selection across the agent1231 // persistMu guards the active session and selection across the agent
1203 // loop, TUI callbacks, and session swaps. Declare it before tool1232 // 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.
1205 var persistMu sync.Mutex1234 var persistMu sync.Mutex
1206 // Serialize session transitions with persistence callbacks so a candidate1235 // Serialize session transitions with persistence callbacks so a candidate
1207 // is read only after the old agent is flushed and cannot be overwritten by1236 // 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 {
1245 extToolAdapter := &extToolAdapter{mgr: extMgr}1274 extToolAdapter := &extToolAdapter{mgr: extMgr}
1246 r.MergeExtensionTools(extToolAdapter)1275 r.MergeExtensionTools(extToolAdapter)
1247 1276
1248- // Build the swarm supervisor BEFORE the agent so the auto-swarm1277+ // Build the subagent supervisor BEFORE the agent so the auto-subagents
1249 // tool can reference it during tool-registry construction. State1278 // tool can reference it during tool-registry construction. State
1250- // lives under ZotHome/swarm so per-agent meta/events survive1279+ // lives under ZotHome/subagents so per-agent meta/events survive
1251 // restarts; the user can hunt orphaned agents down with1280 // restarts; the user can hunt orphaned agents down with
1252 // `git worktree list` if anything misbehaves.1281 // `git worktree list` if anything misbehaves.
1253 //1282 //
1254- // swarmMgr is also captured by loadSession / changeCWD closures1283+ // subagentsMgr is also captured by loadSession / changeCWD closures
1255 // further down the function, which is why we keep the variable1284 // further down the function, which is why we keep the variable
1256 // in this outer scope rather than scoping it tighter.1285 // in this outer scope rather than scoping it tighter.
1257- var swarmMgr *swarm.Swarm1286+ var subagentsMgr *subagents.Supervisor
1258- swarmMgr = swarm.New(swarm.Config{1287+ subagentsMgr = subagents.New(subagents.Config{
1259- Root: filepath.Join(ZotHome(), "swarm"),1288+ Context: ctx,
1260- RepoRoot: r.CWD,1289+ Root: filepath.Join(ZotHome(), "subagents"),
1261- FastMode: r.FastMode,1290+ RepoRoot: r.CWD,
1262- ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) {1291+ Provider: r.Provider,
1263- if providerID == "ollama" {1292+ FastMode: r.FastMode,
1264- return swarm.Credential{Value: "ollama", Method: "apikey"}, nil1293+ 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
1265 }1316 }
1266- credential, method, accountID, err := ResolveCredentialFullContext(ctx, providerID, "")1317+ credential, method, accountID, err := ResolveCredentialFullContext(ctx, childProvider, explicit)
1267 if err != nil {1318 if err != nil {
1268 // Providers backed only by a local endpoint do not need a key.1319 // Providers backed only by a local endpoint do not need a key.
1269 // Leave stdin untouched and let the child resolve that endpoint.1320 // Leave stdin untouched and let the child resolve that endpoint.
1270- if !CredentialAvailable(providerID) {1321+ if !CredentialAvailable(childProvider) {
1271- return swarm.Credential{}, nil1322+ return subagents.Credential{}, nil
1272 }1323 }
1273- return swarm.Credential{}, err1324+ return subagents.Credential{}, err
1274 }1325 }
1275- return swarm.Credential{Value: credential, Method: method, AccountID: accountID}, nil1326+ return subagents.Credential{Value: credential, Method: method, AccountID: accountID}, nil
1276 },1327 },
1277 })1328 })
1278 // Pull any previously-spawned agents off disk so the dashboard1329 // Pull any previously-spawned agents off disk so the dashboard
1279 // shows them as detached and the user can resume / remove them.1330 // 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+ }
1281 1339
1282- // onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool1340+ // onSpawnedSupervisor is the OnSpawned callback the subagent_spawn tool
1283 // fires after every successful spawn. It hands the agent off to1341 // fires after every successful spawn. It hands the agent off to
1284 // the running Interactive so the watcher can flush a summary back1342 // the running Interactive so the watcher can flush a summary back
1285 // into chat when all sub-agents finish. Reads `iv` lazily because1343 // into chat when all sub-agents finish. Reads `iv` lazily because
1286 // the Interactive is constructed after the agent.1344 // the Interactive is constructed after the agent.
1287- onSpawnedSwarm := func(a *swarm.Agent, task string) {1345+ onSpawnedSupervisor := func(a *subagents.Agent, task string) {
1288 if iv != nil {1346 if iv != nil {
1289- iv.TrackSwarmAgent(a, task)1347+ iv.TrackSubagentWorker(a, task)
1290 }1348 }
1291 }1349 }
1292 resolveSubagent := func(name string) (*subagents.Profile, error) {1350 resolveSubagent := func(name string) (*subagents.Profile, error) {
1293 return findSubagentProfile(r.CWD, name)1351 return findSubagentProfile(r.CWD, name)
1294 }1352 }
1295 1353
1296- // Inject the swarm_spawn auto-swarm tool only when /settings ->1354+ // Inject the subagent_spawn auto-subagents tool only when /settings ->
1297- // auto-swarm is currently enabled. Registering it unconditionally1355+ // auto-subagents is currently enabled. Registering it unconditionally
1298 // leaves the model trying to call it (and getting a polite error)1356 // leaves the model trying to call it (and getting a polite error)
1299 // even when the user has switched the feature off. The /settings1357 // even when the user has switched the feature off. The /settings
1300 // toggle live-mutates the running agent's registry separately so1358 // toggle live-mutates the running agent's registry separately so
1301 // flipping the flag mid-session takes effect on the next turn.1359 // flipping the flag mid-session takes effect on the next turn.
1302- injectSwarmSpawn := func(reg core.Registry) core.Registry {1360+ injectSubagentSpawn := func(reg core.Registry) core.Registry {
1303- if reg == nil {1361+ if reg == nil || subagentsMgr == nil || !autoSubagentsToolAllowed(args) {
1304 return reg1362 return reg
1305 }1363 }
1306- if !AutoSwarmEnabled() {1364+ if !AutoSubagentsEnabled() {
1307 return reg1365 return reg
1308 }1366 }
1309- reg["swarm_spawn"] = &tools.SwarmSpawnTool{1367+ canonical := &tools.SubagentSpawnTool{
1310- Swarm: swarmMgr,1368+ Supervisor: subagentsMgr,
1311- Enabled: AutoSwarmEnabled,1369+ Enabled: AutoSubagentsEnabled,
1312 DefaultModel: func() string {1370 DefaultModel: func() string {
1313 persistMu.Lock()1371 persistMu.Lock()
1314 defer persistMu.Unlock()1372 defer persistMu.Unlock()
@@ -1321,11 +1379,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1321 },1379 },
1322 DefaultReasoning: func() string { return r.Reasoning },1380 DefaultReasoning: func() string { return r.Reasoning },
1323 ResolveSubagent: resolveSubagent,1381 ResolveSubagent: resolveSubagent,
1324- OnSpawned: onSpawnedSwarm,1382+ OnSpawned: onSpawnedSupervisor,
1325 }1383 }
1384+ reg[canonical.Name()] = canonical
1326 return reg1385 return reg
1327 }1386 }
1328- injectSwarmSpawn(r.ToolRegistry)1387+ injectSubagentSpawn(r.ToolRegistry)
1329 1388
1330 // Confirmation gate: when --no-yolo is on, the agent must ask1389 // Confirmation gate: when --no-yolo is on, the agent must ask
1331 // the user before every tool call. In interactive mode the TUI1390 // 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 {
1407 return nil, "", "", err1466 return nil, "", "", err
1408 }1467 }
1409 resolved.UseSandbox(sharedSandbox)1468 resolved.UseSandbox(sharedSandbox)
1469+ if subagentsMgr != nil {
1470+ subagentsMgr.SetProvider(resolved.Provider)
1471+ subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1472+ }
1410 resolved.MergeExtensionTools(extToolAdapter)1473 resolved.MergeExtensionTools(extToolAdapter)
1411- injectSwarmSpawn(resolved.ToolRegistry)1474+ injectSubagentSpawn(resolved.ToolRegistry)
1412 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1475 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1413 }1476 }
1414 1477
@@ -1426,8 +1489,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1426 return nil, "", "", err1489 return nil, "", "", err
1427 }1490 }
1428 resolved.UseSandbox(sharedSandbox)1491 resolved.UseSandbox(sharedSandbox)
1492+ if subagentsMgr != nil {
1493+ subagentsMgr.SetProvider(resolved.Provider)
1494+ subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1495+ }
1429 resolved.MergeExtensionTools(extToolAdapter)1496 resolved.MergeExtensionTools(extToolAdapter)
1430- injectSwarmSpawn(resolved.ToolRegistry)1497+ injectSubagentSpawn(resolved.ToolRegistry)
1431 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1498 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1432 }1499 }
1433 1500
@@ -1453,8 +1520,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1453 return nil, "", "", err1520 return nil, "", "", err
1454 }1521 }
1455 resolved.UseSandbox(sharedSandbox)1522 resolved.UseSandbox(sharedSandbox)
1523+ if subagentsMgr != nil {
1524+ subagentsMgr.SetProvider(resolved.Provider)
1525+ subagentsMgr.SetProviderSettings(resolved.BaseURL, resolved.InsecureTLS)
1526+ }
1456 resolved.MergeExtensionTools(extToolAdapter)1527 resolved.MergeExtensionTools(extToolAdapter)
1457- injectSwarmSpawn(resolved.ToolRegistry)1528+ injectSubagentSpawn(resolved.ToolRegistry)
1458 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1529 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1459 }1530 }
1460 1531
@@ -1473,7 +1544,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1473 if current == nil {1544 if current == nil {
1474 return1545 return
1475 }1546 }
1476- refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)1547+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn)
1477 })1548 })
1478 1549
1479 var sess *core.Session1550 var sess *core.Session
@@ -1696,10 +1767,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1696 }1767 }
1697 activeProvider = candidate.provider1768 activeProvider = candidate.provider
1698 activeModel = candidate.model1769 activeModel = candidate.model
1699- if swarmMgr != nil && candidate.session != nil {1770+ if subagentsMgr != nil && candidate.session != nil {
1700 // Keep the dashboard scope in the same commit as the session,1771 // Keep the dashboard scope in the same commit as the session,
1701 // agent, usage, and persistence baseline.1772 // agent, usage, and persistence baseline.
1702- swarmMgr.SetActiveSession(candidate.session.ID)1773+ subagentsMgr.SetActiveSession(candidate.session.ID)
1703 }1774 }
1704 committed = true1775 committed = true
1705 persistMu.Unlock()1776 persistMu.Unlock()
@@ -1718,7 +1789,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1718 //1789 //
1719 // Steps, in order:1790 // Steps, in order:
1720 // 1. resolve + validate the new path (~ expansion, abs/rel)1791 // 1. resolve + validate the new path (~ expansion, abs/rel)
1721- // 2. stage the candidate cwd, sandbox, and swarm state1792+ // 2. stage the candidate cwd, sandbox, and subagent state
1722 // 3. rebuild the agent and allocate its new session1793 // 3. rebuild the agent and allocate its new session
1723 // 4. commit all new state together, closing the old session last1794 // 4. commit all new state together, closing the old session last
1724 // 5. push the committed state into the running Interactive1795 // 5. push the committed state into the running Interactive
@@ -1765,7 +1836,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1765 1836
1766 // Stage the candidate state while retaining a complete rollback1837 // Stage the candidate state while retaining a complete rollback
1767 // snapshot. A failed rebuild or session allocation must leave the1838 // 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.
1769 oldCWD := args.CWD1840 oldCWD := args.CWD
1770 oldResolvedCWD := r.CWD1841 oldResolvedCWD := r.CWD
1771 oldPermissionSet := args.PermissionSet1842 oldPermissionSet := args.PermissionSet
@@ -1773,9 +1844,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1773 oldActiveProvider := activeProvider1844 oldActiveProvider := activeProvider
1774 oldActiveModel := activeModel1845 oldActiveModel := activeModel
1775 persistMu.Unlock()1846 persistMu.Unlock()
1776- oldSwarmRoot := ""1847+ oldSupervisorRoot := ""
1777- if swarmMgr != nil {1848+ if subagentsMgr != nil {
1778- oldSwarmRoot = swarmMgr.RepoRoot()1849+ oldSupervisorRoot = subagentsMgr.RepoRoot()
1779 }1850 }
1780 oldSandboxRoot := ""1851 oldSandboxRoot := ""
1781 oldSandboxLocked := false1852 oldSandboxLocked := false
@@ -1791,8 +1862,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1791 activeProvider = oldActiveProvider1862 activeProvider = oldActiveProvider
1792 activeModel = oldActiveModel1863 activeModel = oldActiveModel
1793 persistMu.Unlock()1864 persistMu.Unlock()
1794- if swarmMgr != nil {1865+ if subagentsMgr != nil {
1795- swarmMgr.SetRepoRoot(oldSwarmRoot)1866+ subagentsMgr.SetRepoRoot(oldSupervisorRoot)
1796 }1867 }
1797 if sharedSandbox != nil {1868 if sharedSandbox != nil {
1798 sharedSandbox.Root = oldSandboxRoot1869 sharedSandbox.Root = oldSandboxRoot
@@ -1806,8 +1877,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1806 1877
1807 args.CWD = absPath1878 args.CWD = absPath
1808 r.CWD = absPath1879 r.CWD = absPath
1809- if swarmMgr != nil {1880+ if subagentsMgr != nil {
1810- swarmMgr.SetRepoRoot(absPath)1881+ subagentsMgr.SetRepoRoot(absPath)
1811 }1882 }
1812 if args.PermissionSet != nil {1883 if args.PermissionSet != nil {
1813 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)1884 expanded := args.PermissionSet.Expand(absPath, args.AgentDataDir)
@@ -1880,9 +1951,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1880 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)1951 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
1881 }1952 }
1882 1953
1883- // Re-scope the swarm dashboard to the new session.1954+ // Re-scope the subagent dashboard to the new session.
1884- if swarmMgr != nil && newSess != nil {1955+ if subagentsMgr != nil && newSess != nil {
1885- swarmMgr.SetActiveSession(newSess.ID)1956+ subagentsMgr.SetActiveSession(newSess.ID)
1886 }1957 }
1887 if newSess != nil {1958 if newSess != nil {
1888 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)1959 extMgr.EmitSessionEvent("session_opened", sessionContext(newSess), newStates)
@@ -1943,7 +2014,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1943 }()2014 }()
1944 }2015 }
1945 2016
1946- initialCfg, _ := LoadConfig()
1947 fastMode := r.FastMode2017 fastMode := r.FastMode
1948 quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))2018 quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))
1949 for idx, s := range initialCfg.QuickModelShortcuts {2019 for idx, s := range initialCfg.QuickModelShortcuts {
@@ -1958,18 +2028,20 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1958 }2028 }
1959 }2029 }
1960 2030
1961- // swarmMgr was constructed and reloaded earlier (before the agent2031+ // subagentsMgr was constructed and reloaded earlier (before the agent
1962- // build, so the auto-swarm tool could capture it). Here we just2032+ // build, so the auto-subagents tool could capture it). Here we just
1963- // scope the dashboard to the active host session so /swarm only2033+ // scope the dashboard to the active host session so /subagents only
1964 // shows agents this session spawned (and any pre-upgrade unscoped2034 // shows agents this session spawned (and any pre-upgrade unscoped
1965 // agents — see SnapshotAll docs). Updated again whenever the2035 // agents — see SnapshotAll docs). Updated again whenever the
1966 // user swaps sessions via loadSession below.2036 // user swaps sessions via loadSession below.
1967- if sess != nil {2037+ if sess != nil && subagentsMgr != nil {
1968- swarmMgr.SetActiveSession(sess.ID)2038+ subagentsMgr.SetActiveSession(sess.ID)
1969 }2039 }
1970 // Best-effort shutdown on interactive exit: stop all running2040 // Best-effort shutdown on interactive exit: stop all running
1971 // agents so they don't outlive their parent zot.2041 // agents so they don't outlive their parent zot.
1972- defer swarmMgr.StopAll()2042+ if subagentsMgr != nil {
2043+ defer subagentsMgr.StopAll()
2044+ }
1973 2045
1974 var startupSkills []*skills.Skill2046 var startupSkills []*skills.Skill
1975 if r.SkillTool != nil {2047 if r.SkillTool != nil {
@@ -1978,54 +2050,56 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1978 homeDir, _ := os.UserHomeDir()2050 homeDir, _ := os.UserHomeDir()
1979 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)2051 discoveredSubagents, _ := subagents.Discover(r.CWD, homeDir)
1980 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)2052 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2053+ autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
1981 2054
1982 iv = modes.NewInteractive(modes.InteractiveConfig{2055 iv = modes.NewInteractive(modes.InteractiveConfig{
1983- Terminal: term,2056+ Terminal: term,
1984- Theme: theme,2057+ Theme: theme,
1985- InlineImagesEnabled: initialCfg.InlineImagesEnabled,2058+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1986- TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,2059+ TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
1987- TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,2060+ TerminalTitleEnabled: initialCfg.TerminalTitleEnabled,
1988- AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,2061+ AutoSubagentsEnabled: initialCfg.AutoSubagentsEnabled,
1989- FastMode: &fastMode,2062+ AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
1990- LSPEnabled: initialCfg.LSPEnabled,2063+ FastMode: &fastMode,
1991- SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,2064+ LSPEnabled: initialCfg.LSPEnabled,
1992- AutoCompactThreshold: initialCfg.AutoCompactThreshold,2065+ SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
1993- JailByDefault: initialCfg.JailByDefault,2066+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1994- QuickModelShortcuts: quickModelShortcuts,2067+ JailByDefault: initialCfg.JailByDefault,
1995- RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,2068+ QuickModelShortcuts: quickModelShortcuts,
1996- RespectGitignore: initialCfg.RespectGitignore,2069+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
1997- CompactMode: initialCfg.CompactMode,2070+ RespectGitignore: initialCfg.RespectGitignore,
1998- TUIInputStyle: initialCfg.TUIInputStyle,2071+ CompactMode: initialCfg.CompactMode,
1999- TUIStatusPosition: initialCfg.TUIStatusPosition,2072+ TUIInputStyle: initialCfg.TUIInputStyle,
2000- TUIWorkingPosition: initialCfg.TUIWorkingPosition,2073+ TUIStatusPosition: initialCfg.TUIStatusPosition,
2001- ThemeName: initialCfg.Theme,2074+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2002- FlatTools: initialCfg.FlatToolRender(),2075+ ThemeName: initialCfg.Theme,
2003- CompactUser: initialCfg.CompactUserInput(),2076+ FlatTools: initialCfg.FlatToolRender(),
2004- ExtensionThemes: extMgr.ThemeOptions,2077+ CompactUser: initialCfg.CompactUserInput(),
2005- AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,2078+ ExtensionThemes: extMgr.ThemeOptions,
2006- SubagentsSystemAddendum: subagentsAddendum,2079+ AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendum,
2007- SettingsStore: configSettingsStore{},2080+ SubagentsSystemAddendum: subagentsAddendum,
2008- Model: r.Model,2081+ SettingsStore: configSettingsStore{},
2009- Provider: r.Provider,2082+ Model: r.Model,
2010- AuthMethod: r.AuthMethod,2083+ Provider: r.Provider,
2011- BaseURL: r.BaseURL,2084+ AuthMethod: r.AuthMethod,
2012- Reasoning: r.Reasoning,2085+ BaseURL: r.BaseURL,
2013- SystemPrompt: r.SystemPrompt,2086+ Reasoning: r.Reasoning,
2014- Tools: r.ToolRegistry,2087+ SystemPrompt: r.SystemPrompt,
2015- MaxSteps: r.MaxSteps,2088+ Tools: r.ToolRegistry,
2016- CWD: r.CWD,2089+ MaxSteps: r.MaxSteps,
2017- StartupAgentName: args.AgentName,2090+ CWD: r.CWD,
2018- StartupContextPaths: instructionContextPaths(r.ContextFiles),2091+ StartupAgentName: args.AgentName,
2019- StartupExtensionNames: startupExtensionNames(extMgr.All()),2092+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
2020- StartupExtensionErrors: startupExtensionErrors,2093+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
2021- StartupSkillNames: startupSkillNames(startupSkills),2094+ StartupExtensionErrors: startupExtensionErrors,
2022- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,2095+ StartupSkillNames: startupSkillNames(startupSkills),
2023- ZotHome: ZotHome(),2096+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2024- SessionsRoot: agentSessionsRoot(ZotHome(), args),2097+ ZotHome: ZotHome(),
2025- Version: version,2098+ SessionsRoot: agentSessionsRoot(ZotHome(), args),
2026- UpdateInfoChan: updateCh,2099+ Version: version,
2027- Sandbox: sharedSandbox,2100+ UpdateInfoChan: updateCh,
2028- Agent: ag,2101+ Sandbox: sharedSandbox,
2102+ Agent: ag,
2029 InitialSessionTitle: func() string {2103 InitialSessionTitle: func() string {
2030 if sess == nil || sessionTitlePending {2104 if sess == nil || sessionTitlePending {
2031 return ""2105 return ""
@@ -2043,12 +2117,12 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2043 _ = extMgr.Reload(context.Background(), 2*time.Second)2117 _ = extMgr.Reload(context.Background(), 2*time.Second)
2044 }2118 }
2045 if current != nil {2119 if current != nil {
2046- refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)2120+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn)
2047 }2121 }
2048 },2122 },
2049 RefreshTools: func() {2123 RefreshTools: func() {
2050 current := liveInteractiveAgent(iv, ag)2124 current := liveInteractiveAgent(iv, ag)
2051- refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)2125+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSubagentSpawn)
2052 },2126 },
2053 AuthManager: mgr,2127 AuthManager: mgr,
2054 LlamaCPPConfig: ResolveLlamaCPPConfig,2128 LlamaCPPConfig: ResolveLlamaCPPConfig,
@@ -2114,7 +2188,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2114 },2188 },
2115 SessionTransition: newSessionTransition(&sessionTransitionMu),2189 SessionTransition: newSessionTransition(&sessionTransitionMu),
2116 Extensions: extMgr,2190 Extensions: extMgr,
2117- Swarm: swarmMgr,2191+ Supervisor: subagentsMgr,
2118 ResolveSubagent: resolveSubagent,2192 ResolveSubagent: resolveSubagent,
2119 ChangelogChan: changelogCh,2193 ChangelogChan: changelogCh,
2120 OnChangelogDismiss: func() {2194 OnChangelogDismiss: func() {
@@ -2238,9 +2312,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2238 }2312 }
2239 closeAgentLSP(finalAg)2313 closeAgentLSP(finalAg)
2240 closeResolvedLSP(r)2314 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+ }
2241 // Exit cleanly. Re-raising the signal would skip os.Exit's2322 // Exit cleanly. Re-raising the signal would skip os.Exit's
2242 // at-exit hooks; explicit exit is fine because we've already2323 // 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.
2244 os.Exit(0)2326 os.Exit(0)
2245 }()2327 }()
2246 2328
@@ -2284,10 +2366,10 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
2284 switch {2366 switch {
2285 case args.Session != "":2367 case args.Session != "":
2286 s, msgs, err = core.OpenSession(args.Session)2368 s, msgs, err = core.OpenSession(args.Session)
2287- // The swarm-agent child passes a fixed --session path that2369+ // The subagent-worker child passes a fixed --session path that
2288 // may not exist yet on first Spawn. Treat ENOENT as "create2370 // may not exist yet on first Spawn. Treat ENOENT as "create
2289 // a fresh session AT THIS PATH" so the conversation actually2371 // a fresh session AT THIS PATH" so the conversation actually
2290- // gets persisted; without this fallback the swarm child runs2372+ // gets persisted; without this fallback the subagent child runs
2291 // with sess==nil and every Resume re-starts with no memory2373 // with sess==nil and every Resume re-starts with no memory
2292 // of the prior turns. Other openers (--continue / --resume /2374 // of the prior turns. Other openers (--continue / --resume /
2293 // the picker) never see ENOENT here because they only choose2375 // the picker) never see ENOENT here because they only choose
M packages/agent/config.go +27 -5
@@ -22,6 +22,25 @@ type QuickModelShortcut struct {
22 Model string `json:"model"`22 Model string `json:"model"`
23 }23 }
24 24
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+
25 // Config is the persisted user configuration.44 // Config is the persisted user configuration.
26 type Config struct {45 type Config struct {
27 Provider string `json:"provider"`46 Provider string `json:"provider"`
@@ -68,17 +87,20 @@ type Config struct {
68 // both so no extra provider request is made. Toggle from /settings.87 // both so no extra provider request is made. Toggle from /settings.
69 TerminalTitleEnabled *bool `json:"terminal_title_enabled,omitempty"`88 TerminalTitleEnabled *bool `json:"terminal_title_enabled,omitempty"`
70 89
71- // AutoSwarmEnabled lets the main agent spawn background sub-agents90+ // Subagents controls manager-owned child lifecycle and resource policy.
72- // for parallel sub-tasks via a built-in swarm_spawn tool. Off by91+ 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
73 // default; nil/missing means disabled. Toggle from /settings.95 // default; nil/missing means disabled. Toggle from /settings.
74- AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`96+ AutoSubagentsEnabled *bool `json:"auto_subagents_enabled,omitempty"`
75 97
76 // LSPEnabled controls the built-in lsp tool and write-time diagnostics98 // LSPEnabled controls the built-in lsp tool and write-time diagnostics
77 // for the main session. nil/missing means enabled. Toggle from99 // for the main session. nil/missing means enabled. Toggle from
78 // /settings.100 // /settings.
79 LSPEnabled *bool `json:"lsp_enabled,omitempty"`101 LSPEnabled *bool `json:"lsp_enabled,omitempty"`
80 102
81- // SubagentLSPEnabled controls LSP availability in swarm sub-agent103+ // SubagentLSPEnabled controls LSP availability in subagent worker
82 // processes. nil/missing means enabled. Toggle from /settings.104 // processes. nil/missing means enabled. Toggle from /settings.
83 SubagentLSPEnabled *bool `json:"subagent_lsp_enabled,omitempty"`105 SubagentLSPEnabled *bool `json:"subagent_lsp_enabled,omitempty"`
84 106
@@ -213,7 +235,7 @@ func (c Config) CompactUserInput() bool {
213 return c.CompactInput != nil && *c.CompactInput235 return c.CompactInput != nil && *c.CompactInput
214 }236 }
215 237
216-// LSPEnabledFor reports whether LSP is enabled for a main or swarm238+// LSPEnabledFor reports whether LSP is enabled for a main or subagent
217 // sub-agent session. Both settings default to true so new installations239 // sub-agent session. Both settings default to true so new installations
218 // get code intelligence without a migration or generated config file.240 // get code intelligence without a migration or generated config file.
219 func (c Config) LSPEnabledFor(subagent bool) bool {241 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 (
7 "testing"7 "testing"
8 "time"8 "time"
9 9
10- "github.com/patriceckhart/zot/packages/agent/swarm"10+ "github.com/patriceckhart/zot/packages/agent/subagents"
11 "github.com/patriceckhart/zot/packages/core"11 "github.com/patriceckhart/zot/packages/core"
12 )12 )
13 13
14-func TestAutoSwarmSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) {14+func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) {
15 iv := &Interactive{15 iv := &Interactive{
16 agent: &core.Agent{System: "base system"},16 agent: &core.Agent{System: "base system"},
17 cfg: InteractiveConfig{17 cfg: InteractiveConfig{
18- AutoSwarmSystemAddendum: "auto-swarm guidance",18+ AutoSubagentsSystemAddendum: "auto-subagents guidance",
19- SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",19+ SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",
20 },20 },
21 }21 }
22 22
23- iv.applyAutoSwarmSystemPrompt(true)23+ iv.applyAutoSubagentsSystemPrompt(true)
24- if !strings.Contains(iv.agent.System, "[subagents_list]") || !strings.Contains(iv.agent.System, "auto-swarm guidance") {24+ if !strings.Contains(iv.agent.System, "[subagents_list]") || !strings.Contains(iv.agent.System, "auto-subagents guidance") {
25- t.Fatalf("enabled system prompt missing swarm blocks: %q", iv.agent.System)25+ t.Fatalf("enabled system prompt missing subagent blocks: %q", iv.agent.System)
26 }26 }
27 before := iv.agent.System27 before := iv.agent.System
28- iv.applyAutoSwarmSystemPrompt(true)28+ iv.applyAutoSubagentsSystemPrompt(true)
29 if iv.agent.System != before {29 if iv.agent.System != before {
30 t.Fatalf("enabling twice changed system prompt: %q -> %q", before, iv.agent.System)30 t.Fatalf("enabling twice changed system prompt: %q -> %q", before, iv.agent.System)
31 }31 }
32 32
33- iv.applyAutoSwarmSystemPrompt(false)33+ iv.applyAutoSubagentsSystemPrompt(false)
34- if strings.Contains(iv.agent.System, "[subagents_list]") || strings.Contains(iv.agent.System, "auto-swarm guidance") {34+ if strings.Contains(iv.agent.System, "[subagents_list]") || strings.Contains(iv.agent.System, "auto-subagents guidance") {
35- t.Fatalf("disabled system prompt retained swarm blocks: %q", iv.agent.System)35+ t.Fatalf("disabled system prompt retained subagent blocks: %q", iv.agent.System)
36 }36 }
37 }37 }
38 38
39-func TestAutoSwarmTogglePreservesMatchingBasePromptBlocks(t *testing.T) {39+func TestAutoSubagentsTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
40 manifest := "[subagents_list]\n- reviewer\n[/subagents_list]"40 manifest := "[subagents_list]\n- reviewer\n[/subagents_list]"
41- guidance := "auto-swarm guidance"41+ guidance := "auto-subagents guidance"
42 base := "base system\n\n" + manifest + "\n\n" + guidance42 base := "base system\n\n" + manifest + "\n\n" + guidance
43 iv := &Interactive{43 iv := &Interactive{
44 agent: &core.Agent{System: base},44 agent: &core.Agent{System: base},
45 cfg: InteractiveConfig{45 cfg: InteractiveConfig{
46- AutoSwarmSystemAddendum: guidance,46+ AutoSubagentsSystemAddendum: guidance,
47- SubagentsSystemAddendum: manifest,47+ SubagentsSystemAddendum: manifest,
48 },48 },
49 }49 }
50 50
51- iv.applyAutoSwarmSystemPrompt(true)51+ iv.applyAutoSubagentsSystemPrompt(true)
52 if strings.Count(iv.agent.System, manifest) != 2 || strings.Count(iv.agent.System, guidance) != 2 {52 if strings.Count(iv.agent.System, manifest) != 2 || strings.Count(iv.agent.System, guidance) != 2 {
53 t.Fatalf("enabling should append owned duplicate blocks: %q", iv.agent.System)53 t.Fatalf("enabling should append owned duplicate blocks: %q", iv.agent.System)
54 }54 }
55- iv.applyAutoSwarmSystemPrompt(false)55+ iv.applyAutoSubagentsSystemPrompt(false)
56 if got := strings.TrimSpace(iv.agent.System); got != base {56 if got := strings.TrimSpace(iv.agent.System); got != base {
57 t.Fatalf("disabling changed matching base prompt: %q; want %q", got, base)57 t.Fatalf("disabling changed matching base prompt: %q; want %q", got, base)
58 }58 }
59 }59 }
60 60
61-func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {61+func TestAutoSubagentsSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {
62 response := "first response line\n" + strings.Repeat("result ", 150) + "final marker"62 response := "first response line\n" + strings.Repeat("result ", 150) + "final marker"
63- mgr := swarm.New(swarm.Config{63+ mgr := subagents.New(subagents.Config{
64 Root: t.TempDir(),64 Root: t.TempDir(),
65 RepoRoot: t.TempDir(),65 RepoRoot: t.TempDir(),
66- NewRunner: func(*swarm.Agent) swarm.Runner {66+ NewRunner: func(*subagents.Agent) subagents.Runner {
67- return swarm.RunnerFunc(func(_ context.Context, sink swarm.Sink) error {67+ return subagents.RunnerFunc(func(_ context.Context, sink subagents.Sink) error {
68 sink.Transcript(response)68 sink.Transcript(response)
69 return nil69 return nil
70 })70 })
@@ -75,20 +75,20 @@ func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T
75 t.Fatal(err)75 t.Fatal(err)
76 }76 }
77 77
78- iv := newQueuedAutoSwarmInteractive()78+ iv := newQueuedAutoSubagentsInteractive()
79- iv.TrackSwarmAgent(a, a.Task)79+ iv.TrackSubagentWorker(a, a.Task)
80 update := waitForQueuedPrompt(t, iv)80 update := waitForQueuedPrompt(t, iv)
81 if !strings.Contains(update, "final response:\n"+response) {81 if !strings.Contains(update, "final response:\n"+response) {
82 t.Fatalf("update missing complete final response: %q", update)82 t.Fatalf("update missing complete final response: %q", update)
83 }83 }
84 }84 }
85 85
86-func TestTrackSwarmAgentReportsStartupFailure(t *testing.T) {86+func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) {
87- mgr := swarm.New(swarm.Config{87+ mgr := subagents.New(subagents.Config{
88 Root: t.TempDir(),88 Root: t.TempDir(),
89 RepoRoot: t.TempDir(),89 RepoRoot: t.TempDir(),
90- NewRunner: func(*swarm.Agent) swarm.Runner {90+ NewRunner: func(*subagents.Agent) subagents.Runner {
91- return swarm.RunnerFunc(func(context.Context, swarm.Sink) error {91+ return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
92 return errors.New("listener startup failed")92 return errors.New("listener startup failed")
93 })93 })
94 },94 },
@@ -98,8 +98,8 @@ func TestTrackSwarmAgentReportsStartupFailure(t *testing.T) {
98 t.Fatal(err)98 t.Fatal(err)
99 }99 }
100 100
101- iv := newQueuedAutoSwarmInteractive()101+ iv := newQueuedAutoSubagentsInteractive()
102- iv.TrackSwarmAgent(a, a.Task)102+ iv.TrackSubagentWorker(a, a.Task)
103 103
104 update := waitForQueuedPrompt(t, iv)104 update := waitForQueuedPrompt(t, iv)
105 if !strings.Contains(update, "status: failed") {105 if !strings.Contains(update, "status: failed") {
@@ -110,13 +110,13 @@ func TestTrackSwarmAgentReportsStartupFailure(t *testing.T) {
110 }110 }
111 }111 }
112 112
113-func TestCompleteSwarmWatchReportsTurnOutcomeOnce(t *testing.T) {113+func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
114 started := make(chan struct{})114 started := make(chan struct{})
115- mgr := swarm.New(swarm.Config{115+ mgr := subagents.New(subagents.Config{
116 Root: t.TempDir(),116 Root: t.TempDir(),
117 RepoRoot: t.TempDir(),117 RepoRoot: t.TempDir(),
118- NewRunner: func(*swarm.Agent) swarm.Runner {118+ NewRunner: func(*subagents.Agent) subagents.Runner {
119- return swarm.RunnerFunc(func(ctx context.Context, _ swarm.Sink) error {119+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
120 close(started)120 close(started)
121 <-ctx.Done()121 <-ctx.Done()
122 return ctx.Err()122 return ctx.Err()
@@ -133,11 +133,11 @@ func TestCompleteSwarmWatchReportsTurnOutcomeOnce(t *testing.T) {
133 a.Wait()133 a.Wait()
134 })134 })
135 135
136- iv := newQueuedAutoSwarmInteractive()136+ iv := newQueuedAutoSubagentsInteractive()
137- entry := &swarmWatchEntry{agent: a, task: a.Task}137+ entry := &subagentWatchEntry{agent: a, task: a.Task}
138- iv.swarmWatch = []*swarmWatchEntry{entry}138+ iv.subagentWatch = []*subagentWatchEntry{entry}
139- iv.completeSwarmWatchEntry(entry, "completed", "")139+ iv.completeSupervisorWatchEntry(entry, "completed", "")
140- iv.completeSwarmWatchEntry(entry, "failed", "late error")140+ iv.completeSupervisorWatchEntry(entry, "failed", "late error")
141 141
142 update := waitForQueuedPrompt(t, iv)142 update := waitForQueuedPrompt(t, iv)
143 if !strings.Contains(update, "status: completed") {143 if !strings.Contains(update, "status: completed") {
@@ -151,7 +151,7 @@ func TestCompleteSwarmWatchReportsTurnOutcomeOnce(t *testing.T) {
151 }151 }
152 }152 }
153 153
154-func newQueuedAutoSwarmInteractive() *Interactive {154+func newQueuedAutoSubagentsInteractive() *Interactive {
155 return &Interactive{155 return &Interactive{
156 agent: &core.Agent{},156 agent: &core.Agent{},
157 busy: true,157 busy: true,
@@ -176,7 +176,7 @@ func waitForQueuedPrompt(t *testing.T, iv *Interactive) string {
176 iv.mu.Unlock()176 iv.mu.Unlock()
177 select {177 select {
178 case <-deadline.C:178 case <-deadline.C:
179- t.Fatal("timed out waiting for auto-swarm update")179+ t.Fatal("timed out waiting for auto-subagents update")
180 case <-ticker.C:180 case <-ticker.C:
181 }181 }
182 }182 }
M packages/agent/modes/dialog_text_test.go +7 -7
@@ -6,7 +6,7 @@ import (
6 6
7 "github.com/mattn/go-runewidth"7 "github.com/mattn/go-runewidth"
8 8
9- "github.com/patriceckhart/zot/packages/agent/swarm"9+ "github.com/patriceckhart/zot/packages/agent/subagents"
10 "github.com/patriceckhart/zot/packages/tui"10 "github.com/patriceckhart/zot/packages/tui"
11 )11 )
12 12
@@ -47,10 +47,10 @@ func TestBtwDialogWrapsAssistantAndKeepsCursorAligned(t *testing.T) {
47 }47 }
48 }48 }
49 49
50-func TestSwarmTranscriptWrapsAssistantAndDiagnosticRows(t *testing.T) {50+func TestSupervisorTranscriptWrapsAssistantAndDiagnosticRows(t *testing.T) {
51 const width = 8051 const width = 80
52 long := strings.Repeat("long response text ", 8)52 long := strings.Repeat("long response text ", 8)
53- rows := renderSwarmTranscriptBlocks([]string{53+ rows := renderSupervisorTranscriptBlocks([]string{
54 long,54 long,
55 "stderr: " + long,55 "stderr: " + long,
56 "error: " + long,56 "error: " + long,
@@ -65,15 +65,15 @@ func TestSwarmTranscriptWrapsAssistantAndDiagnosticRows(t *testing.T) {
65 }65 }
66 }66 }
67 67
68-func TestSwarmDialogViewingWrapsSpawnedAgentResponse(t *testing.T) {68+func TestSubagentsDialogViewingWrapsSpawnedAgentResponse(t *testing.T) {
69 const width = 8069 const width = 80
70 response := strings.Repeat("spawned agent response ", 6)70 response := strings.Repeat("spawned agent response ", 6)
71- rows := []swarm.AgentSnapshot{{71+ rows := []subagents.AgentSnapshot{{
72 ID: "agent-1",72 ID: "agent-1",
73- Status: swarm.StatusDone,73+ Status: subagents.StatusDone,
74 Lines: []string{response},74 Lines: []string{response},
75 }}75 }}
76- d := newSwarmDialog()76+ d := newSubagentsDialog()
77 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")77 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
78 _ = d.Render(tui.Theme{}, width)78 _ = d.Render(tui.Theme{}, width)
79 d.HandleKey(tui.Key{Kind: tui.KeyEnter})79 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 {
79 i.llamaDialog != nil && i.llamaDialog.Active() ||79 i.llamaDialog != nil && i.llamaDialog.Active() ||
80 i.rescueDialog != nil && i.rescueDialog.Active() ||80 i.rescueDialog != nil && i.rescueDialog.Active() ||
81 i.sessionDialog != nil && i.sessionDialog.Active() ||81 i.sessionDialog != nil && i.sessionDialog.Active() ||
82- i.swarmDialog != nil && i.swarmDialog.Active() ||82+ i.subagentsDialog != nil && i.subagentsDialog.Active() ||
83 i.jumpDialog != nil && i.jumpDialog.Active() ||83 i.jumpDialog != nil && i.jumpDialog.Active() ||
84 i.btwDialog != nil && i.btwDialog.Active() ||84 i.btwDialog != nil && i.btwDialog.Active() ||
85 i.skillsDialog != nil && i.skillsDialog.Active() ||85 i.skillsDialog != nil && i.skillsDialog.Active() ||
M packages/agent/modes/interactive.go +217 -144
@@ -18,7 +18,6 @@ import (
18 "github.com/patriceckhart/zot/packages/agent/modes/telegram"18 "github.com/patriceckhart/zot/packages/agent/modes/telegram"
19 "github.com/patriceckhart/zot/packages/agent/skills"19 "github.com/patriceckhart/zot/packages/agent/skills"
20 "github.com/patriceckhart/zot/packages/agent/subagents"20 "github.com/patriceckhart/zot/packages/agent/subagents"
21- "github.com/patriceckhart/zot/packages/agent/swarm"
22 "github.com/patriceckhart/zot/packages/agent/tools"21 "github.com/patriceckhart/zot/packages/agent/tools"
23 "github.com/patriceckhart/zot/packages/core"22 "github.com/patriceckhart/zot/packages/core"
24 "github.com/patriceckhart/zot/packages/provider"23 "github.com/patriceckhart/zot/packages/provider"
@@ -68,10 +67,14 @@ type InteractiveConfig struct {
68 // title updates. nil means enabled; false disables both.67 // title updates. nil means enabled; false disables both.
69 TerminalTitleEnabled *bool68 TerminalTitleEnabled *bool
70 69
71- // AutoSwarmEnabled mirrors the persisted config flag at startup so70+ // AutoSubagentsEnabled mirrors the persisted config flag at startup so
72 // the /settings dialog can render the current state without71 // the /settings dialog can render the current state without
73 // re-reading config.json on every open.72 // re-reading config.json on every open.
74- AutoSwarmEnabled *bool73+ 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
75 78
76 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.79 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.
77 // nil/missing means disabled. Unsupported providers reject attempts80 // nil/missing means disabled. Unsupported providers reject attempts
@@ -82,7 +85,7 @@ type InteractiveConfig struct {
82 // enabled by default.85 // enabled by default.
83 LSPEnabled *bool86 LSPEnabled *bool
84 87
85- // SubagentLSPEnabled controls whether newly spawned swarm children88+ // SubagentLSPEnabled controls whether newly spawned subagent children
86 // receive the lsp tool. nil means enabled by default.89 // receive the lsp tool. nil means enabled by default.
87 SubagentLSPEnabled *bool90 SubagentLSPEnabled *bool
88 91
@@ -141,14 +144,14 @@ type InteractiveConfig struct {
141 // ExtensionThemes returns themes bundled with loaded extensions.144 // ExtensionThemes returns themes bundled with loaded extensions.
142 ExtensionThemes func() []tui.ThemeOption145 ExtensionThemes func() []tui.ThemeOption
143 146
144- // AutoSwarmSystemAddendum is the system-prompt block that gets147+ // AutoSubagentsSystemAddendum is the system-prompt block that gets
145- // appended/stripped when the user toggles auto-swarm at runtime.148+ // appended/stripped when the user toggles auto-subagents at runtime.
146 // Plumbed in from the cli so this package doesn't have to import149 // Plumbed in from the cli so this package doesn't have to import
147 // agent (cycle).150 // agent (cycle).
148- AutoSwarmSystemAddendum string151+ AutoSubagentsSystemAddendum string
149 152
150 // SubagentsSystemAddendum is the metadata-only [subagents_list]153 // 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.
152 SubagentsSystemAddendum string155 SubagentsSystemAddendum string
153 SettingsStore SettingsStore156 SettingsStore SettingsStore
154 157
@@ -315,15 +318,15 @@ type InteractiveConfig struct {
315 // AFTER the built-in catalog so a built-in name always wins.318 // AFTER the built-in catalog so a built-in name always wins.
316 Extensions *extensions.Manager319 Extensions *extensions.Manager
317 320
318- // Swarm, if non-nil, enables the /swarm slash command and the321+ // Supervisor, if non-nil, enables the /subagents slash command and the
319- // dashboard dialog. The cli constructs the Swarm once per322+ // dashboard dialog. The cli constructs the Supervisor once per
320 // interactive run and tears it down on exit. nil disables the323 // interactive run and tears it down on exit. nil disables the
321 // feature entirely (used by embedders / tests that don't want324 // feature entirely (used by embedders / tests that don't want
322 // subprocesses).325 // subprocesses).
323- Swarm *swarm.Swarm326+ Supervisor *subagents.Supervisor
324 327
325 // ResolveSubagent validates a named markdown profile for direct328 // ResolveSubagent validates a named markdown profile for direct
326- // /swarm commands. Auto-swarm uses the equivalent callback on its329+ // /subagents commands. Auto-subagents uses the equivalent callback on its
327 // tool; keeping this optional preserves lightweight embedders.330 // tool; keeping this optional preserves lightweight embedders.
328 ResolveSubagent func(name string) (*subagents.Profile, error)331 ResolveSubagent func(name string) (*subagents.Profile, error)
329 332
@@ -410,7 +413,7 @@ type extensionWidget struct {
410 type SettingsStore interface {413 type SettingsStore interface {
411 SetQuickModelShortcut(slot int, providerName, model string) error414 SetQuickModelShortcut(slot int, providerName, model string) error
412 SetInlineImages(enabled bool) error415 SetInlineImages(enabled bool) error
413- SetAutoSwarm(enabled bool) error416+ SetAutoSubagents(enabled bool) error
414 SetJailByDefault(enabled bool) error417 SetJailByDefault(enabled bool) error
415 SetRecursiveFileSuggest(enabled bool) error418 SetRecursiveFileSuggest(enabled bool) error
416 SetRespectGitignore(enabled bool) error419 SetRespectGitignore(enabled bool) error
@@ -455,13 +458,16 @@ type Interactive struct {
455 458
456 mu sync.Mutex459 mu sync.Mutex
457 agent *core.Agent460 agent *core.Agent
458- // managedAutoSwarmAddenda records exact prompt blocks appended by461+ // agentMu serializes live registry replacement with side-dialog
459- // auto-swarm. Disable only removes these owned occurrences, leaving462+ // 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
460 // identical text that came from the user's base prompt untouched.466 // identical text that came from the user's base prompt untouched.
461- managedAutoSwarmAddenda []string467+ managedAutoSubagentsAddenda []string
462- streaming strings.Builder // what's currently painted on screen468+ streaming strings.Builder // what's currently painted on screen
463- streamOn bool469+ streamOn bool
464- pendingAlert *extproto.AlertRequest470+ pendingAlert *extproto.AlertRequest
465 471
466 // streamPending is the runes buffered after each EvTextDelta that472 // streamPending is the runes buffered after each EvTextDelta that
467 // haven't yet been promoted into `streaming` for rendering. It473 // haven't yet been promoted into `streaming` for rendering. It
@@ -572,7 +578,7 @@ type Interactive struct {
572 llamaDialog *llamaDialog578 llamaDialog *llamaDialog
573 rescueDialog *rescueDialog579 rescueDialog *rescueDialog
574 sessionDialog *sessionDialog580 sessionDialog *sessionDialog
575- swarmDialog *swarmDialog581+ subagentsDialog *subagentsDialog
576 jumpDialog *jumpDialog582 jumpDialog *jumpDialog
577 btwDialog *btwDialog583 btwDialog *btwDialog
578 skillsDialog *skillsDialog584 skillsDialog *skillsDialog
@@ -588,14 +594,18 @@ type Interactive struct {
588 extPanel *extPanelDialog594 extPanel *extPanelDialog
589 llamaConfigured bool595 llamaConfigured bool
590 596
591- // swarmWatch tracks auto-swarm sub-agents the main agent spawned597+ // subagentWatch tracks auto-subagents sub-agents the main agent spawned
592- // via swarm_spawn. Each entry holds the agent + the task text;598+ // via subagent_spawn. Each entry holds the agent + the task text;
593 // a per-entry goroutine waits on the agent's terminal state. When599 // a per-entry goroutine waits on the agent's terminal state. When
594 // every tracked entry has finished, the watcher flushes a single600 // every tracked entry has finished, the watcher flushes a single
595 // summary turn into the main chat (queued if the main agent is601 // summary turn into the main chat (queued if the main agent is
596 // busy, run immediately if idle).602 // busy, run immediately if idle).
597- swarmWatchMu sync.Mutex603+ subagentWatchMu sync.Mutex
598- swarmWatch []*swarmWatchEntry604+ subagentWatch []*subagentWatchEntry
605+
606+ // subagentsWaitWatcherDone is an optional test hook invoked when a
607+ // /subagents wait watcher exits.
608+ subagentsWaitWatcherDone func()
599 609
600 // pendingFork is true when the user ran /session fork: the next610 // pendingFork is true when the user ran /session fork: the next
601 // jump-picker selection should branch off that message instead611 // jump-picker selection should branch off that message instead
@@ -752,7 +762,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
752 llamaDialog: newLlamaDialog(),762 llamaDialog: newLlamaDialog(),
753 rescueDialog: newRescueDialog(),763 rescueDialog: newRescueDialog(),
754 sessionDialog: newSessionDialog(),764 sessionDialog: newSessionDialog(),
755- swarmDialog: newSwarmDialog(),765+ subagentsDialog: newSubagentsDialog(),
756 jumpDialog: newJumpDialog(),766 jumpDialog: newJumpDialog(),
757 btwDialog: newBtwDialog(),767 btwDialog: newBtwDialog(),
758 skillsDialog: newSkillsDialog(),768 skillsDialog: newSkillsDialog(),
@@ -779,8 +789,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
779 baseURL, _, err := cfg.LlamaCPPConfig()789 baseURL, _, err := cfg.LlamaCPPConfig()
780 i.llamaConfigured = err == nil && baseURL != ""790 i.llamaConfigured = err == nil && baseURL != ""
781 }791 }
782- if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {792+ if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled && cfg.Supervisor != nil && (cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed) {
783- i.managedAutoSwarmAddenda = autoSwarmAddenda(cfg)793+ i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)
784 }794 }
785 if cfg.Agent != nil {795 if cfg.Agent != nil {
786 i.agent = cfg.Agent796 i.agent = cfg.Agent
@@ -808,8 +818,8 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
808 i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages)818 i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages)
809 i.titleGenerationStarted = i.titleRealPromptSeen819 i.titleGenerationStarted = i.titleRealPromptSeen
810 }820 }
811- if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {821+ if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
812- i.applyAutoSwarmTool(true)822+ i.applyAutoSubagentsTool(true)
813 }823 }
814 return i824 return i
815 }825 }
@@ -1066,7 +1076,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1066 // blink inside dialogs that host their own editor (btw),1076 // blink inside dialogs that host their own editor (btw),
1067 // because each frame re-emits hide-cursor + show-cursor.1077 // because each frame re-emits hide-cursor + show-cursor.
1068 //1078 //
1069- // The swarm dashboard is also animated: its rows reflect1079+ // The subagent dashboard is also animated: its rows reflect
1070 // background subprocesses whose activity / age change1080 // background subprocesses whose activity / age change
1071 // without any user input. Without the tick redraw the1081 // without any user input. Without the tick redraw the
1072 // dashboard freezes on the snapshot taken when the user1082 // dashboard freezes on the snapshot taken when the user
@@ -1074,7 +1084,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1074 // of its inline editors (spawn task or prompt composer)1084 // of its inline editors (spawn task or prompt composer)
1075 // is active so the cursor blink in those editors works1085 // is active so the cursor blink in those editors works
1076 // the same way it does inside btw.1086 // 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() {
1078 requestRedraw()1088 requestRedraw()
1079 }1089 }
1080 }1090 }
@@ -1541,8 +1551,8 @@ func (i *Interactive) redraw() {
1541 }1551 }
1542 i.sessionDialog.MaxRows = avail1552 i.sessionDialog.MaxRows = avail
1543 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)1553 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
1544- case i.swarmDialog.Active():1554+ case i.subagentsDialog.Active():
1545- dialog = i.swarmDialog.Render(i.cfg.Theme, cols)1555+ dialog = i.subagentsDialog.Render(i.cfg.Theme, cols)
1546 case i.jumpDialog.Active():1556 case i.jumpDialog.Active():
1547 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)1557 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
1548 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):1558 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
@@ -1692,6 +1702,7 @@ func (i *Interactive) redraw() {
1692 Provider: i.cfg.Provider,1702 Provider: i.cfg.Provider,
1693 Model: i.cfg.Model,1703 Model: i.cfg.Model,
1694 Reasoning: i.cfg.Reasoning,1704 Reasoning: i.cfg.Reasoning,
1705+ FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
1695 Busy: i.busy,1706 Busy: i.busy,
1696 BusyPrefix: statusBusyPrefix,1707 BusyPrefix: statusBusyPrefix,
1697 CWD: i.cfg.CWD,1708 CWD: i.cfg.CWD,
@@ -1765,15 +1776,15 @@ func (i *Interactive) redraw() {
1765 bottom = append(bottom, "")1776 bottom = append(bottom, "")
1766 }1777 }
1767 bottom = append(bottom, dialog...)1778 bottom = append(bottom, dialog...)
1768- // The swarm dashboard owns the bottom of the screen while it's1779+ // The subagent dashboard owns the bottom of the screen while it's
1769 // active: it has its own inline editors for spawn (`n`) and1780 // active: it has its own inline editors for spawn (`n`) and
1770 // prompt (`p`), so the main input would be a confusing second1781 // prompt (`p`), so the main input would be a confusing second
1771 // caret. The suggest popup, sliding-in queue, status block, and1782 // caret. The suggest popup, sliding-in queue, status block, and
1772 // main editor are all hidden underneath it. Keystrokes still1783 // main editor are all hidden underneath it. Keystrokes still
1773- // reach handleKey — it routes them to swarmDialog.HandleKey1784+ // reach handleKey — it routes them to subagentsDialog.HandleKey
1774 // before the editor ever sees them — so the only effect of this1785 // before the editor ever sees them — so the only effect of this
1775 // branch is visual.1786 // branch is visual.
1776- if !i.swarmDialog.Active() {1787+ if !i.subagentsDialog.Active() {
1777 bottom = append(bottom, suggest...)1788 bottom = append(bottom, suggest...)
1778 bottom = append(bottom, queue...)1789 bottom = append(bottom, queue...)
1779 lineInput := inputStyle == tui.InputStyleLines1790 lineInput := inputStyle == tui.InputStyleLines
@@ -2004,8 +2015,8 @@ func (i *Interactive) redraw() {
2004 cursorCol = c2015 cursorCol = c
2005 }2016 }
2006 }2017 }
2007- if i.swarmDialog.Active() {2018+ if i.subagentsDialog.Active() {
2008- if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {2019+ if r, c := i.subagentsDialog.CursorPos(cols); r >= 0 {
2009 cursorRow = dialogLead + r2020 cursorRow = dialogLead + r
2010 cursorCol = c2021 cursorCol = c
2011 } else {2022 } else {
@@ -2307,7 +2318,7 @@ func (i *Interactive) confirmChildActive() bool {
2307 i.llamaDialog.Active() ||2318 i.llamaDialog.Active() ||
2308 i.rescueDialog.Active() ||2319 i.rescueDialog.Active() ||
2309 i.sessionDialog.Active() ||2320 i.sessionDialog.Active() ||
2310- i.swarmDialog.Active() ||2321+ i.subagentsDialog.Active() ||
2311 i.jumpDialog.Active() ||2322 i.jumpDialog.Active() ||
2312 i.btwDialog.Active() ||2323 i.btwDialog.Active() ||
2313 i.skillsDialog.Active() ||2324 i.skillsDialog.Active() ||
@@ -2515,13 +2526,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2515 i.invalidate()2526 i.invalidate()
2516 return false2527 return false
2517 }2528 }
2518- if i.swarmDialog.Active() {2529+ if i.subagentsDialog.Active() {
2519 if k.Kind == tui.KeyCtrlC {2530 if k.Kind == tui.KeyCtrlC {
2520- i.swarmDialog.Close()2531+ i.subagentsDialog.Close()
2521 i.invalidate()2532 i.invalidate()
2522 return false2533 return false
2523 }2534 }
2524- _, msg, errMsg := i.swarmDialog.HandleKey(k)2535+ _, msg, errMsg := i.subagentsDialog.HandleKey(k)
2525 if msg != "" || errMsg != "" {2536 if msg != "" || errMsg != "" {
2526 i.mu.Lock()2537 i.mu.Lock()
2527 i.statusOK = msg2538 i.statusOK = msg
@@ -3500,10 +3511,10 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
3500 i.agent = ag3511 i.agent = ag
3501 i.cfg.CWD = cwd3512 i.cfg.CWD = cwd
3502 i.cfg.SubagentsSystemAddendum = subagentsAddendum3513 i.cfg.SubagentsSystemAddendum = subagentsAddendum
3503- if i.autoSwarmEnabled() {3514+ if i.autoSubagentsEnabledLocked() {
3504- i.managedAutoSwarmAddenda = autoSwarmAddenda(i.cfg)3515+ i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg)
3505 } else {3516 } else {
3506- i.managedAutoSwarmAddenda = nil3517+ i.managedAutoSubagentsAddenda = nil
3507 }3518 }
3508 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)3519 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
3509 i.view.StartupContextPaths = nil3520 i.view.StartupContextPaths = nil
@@ -3847,15 +3858,14 @@ func (i *Interactive) openSettingsDialog() {
3847 3858
3848 terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)3859 terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
3849 terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled)3860 terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled)
3850- autoSwarm := false3861+ autoSubagents := false
3851- if i.cfg.AutoSwarmEnabled != nil {3862+ if i.cfg.AutoSubagentsEnabled != nil {
3852- autoSwarm = *i.cfg.AutoSwarmEnabled3863+ autoSubagents = *i.cfg.AutoSubagentsEnabled
3853 }3864 }
3854- autoSwarmDisabled := i.cfg.Swarm == nil3865+ autoSubagentsDisabled := !i.autoSubagentsAvailable()
3855- autoSwarmHint := ""3866+ autoSubagentsHint := i.autoSubagentsUnavailableHint()
3856- if autoSwarmDisabled {3867+ if autoSubagentsDisabled {
3857- autoSwarm = false3868+ autoSubagents = false
3858- autoSwarmHint = "swarm supervisor not available in this mode"
3859 }3869 }
3860 3870
3861 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode3871 fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
@@ -3977,12 +3987,12 @@ func (i *Interactive) openSettingsDialog() {
3977 value: terminalTitles,3987 value: terminalTitles,
3978 },3988 },
3979 {3989 {
3980- key: "auto_swarm_enabled",3990+ key: "auto_subagents_enabled",
3981- label: "auto-swarm",3991+ label: "auto-subagents",
3982- desc: "let the agent spawn background sub-agents in parallel via the swarm_spawn tool",3992+ desc: "let the agent spawn background sub-agents in parallel via the subagent_spawn tool",
3983- value: autoSwarm,3993+ value: autoSubagents,
3984- disabled: autoSwarmDisabled,3994+ disabled: autoSubagentsDisabled,
3985- hint: autoSwarmHint,3995+ hint: autoSubagentsHint,
3986 },3996 },
3987 {3997 {
3988 key: "fast_mode",3998 key: "fast_mode",
@@ -4388,28 +4398,37 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4388 i.statusOK = "AI terminal titles " + onOff(value)4398 i.statusOK = "AI terminal titles " + onOff(value)
4389 i.statusErr = ""4399 i.statusErr = ""
4390 i.mu.Unlock()4400 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+ }
4392 val := value4409 val := value
4393- i.cfg.AutoSwarmEnabled = &val4410+ i.mu.Lock()
4411+ i.cfg.AutoSubagentsEnabled = &val
4412+ i.mu.Unlock()
4394 if i.cfg.SettingsStore != nil {4413 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 {
4396 i.mu.Lock()4415 i.mu.Lock()
4397 i.statusErr = "settings: " + err.Error()4416 i.statusErr = "settings: " + err.Error()
4398 i.mu.Unlock()4417 i.mu.Unlock()
4399 return4418 return
4400 }4419 }
4401 }4420 }
4402- // Add/remove the swarm_spawn tool on the live agent so the4421+ // Add/remove the subagent_spawn tool on the live agent so the
4403 // model's tools[] list reflects the toggle on the next turn.4422 // model's tools[] list reflects the toggle on the next turn.
4404 // Without this the tool stays advertised after a disable and4423 // Without this the tool stays advertised after a disable and
4405 // the model keeps trying to call it.4424 // the model keeps trying to call it.
4406- i.applyAutoSwarmTool(value)4425+ i.applyAutoSubagentsTool(value)
4407 // Also swap the system-prompt addendum in/out so the model4426 // Also swap the system-prompt addendum in/out so the model
4408 // knows to use the tool proactively (or stops referencing it4427 // knows to use the tool proactively (or stops referencing it
4409 // after a disable).4428 // after a disable).
4410- i.applyAutoSwarmSystemPrompt(value)4429+ i.applyAutoSubagentsSystemPrompt(value)
4411 i.mu.Lock()4430 i.mu.Lock()
4412- i.statusOK = "auto-swarm " + onOff(value)4431+ i.statusOK = "auto-subagents " + onOff(value)
4413 i.statusErr = ""4432 i.statusErr = ""
4414 i.mu.Unlock()4433 i.mu.Unlock()
4415 case "fast_mode":4434 case "fast_mode":
@@ -4435,8 +4454,8 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
4435 if i.agent != nil {4454 if i.agent != nil {
4436 i.agent.SetFastMode(value)4455 i.agent.SetFastMode(value)
4437 }4456 }
4438- if i.cfg.Swarm != nil {4457+ if i.cfg.Supervisor != nil {
4439- i.cfg.Swarm.SetFastMode(value)4458+ i.cfg.Supervisor.SetFastMode(value)
4440 }4459 }
4441 i.mu.Lock()4460 i.mu.Lock()
4442 i.statusOK = "fast mode " + onOff(value)4461 i.statusOK = "fast mode " + onOff(value)
@@ -4799,7 +4818,7 @@ func buildStudyPrompt(arg, cwd string) string {
4799 // keeps Tab as a literal no-op.4818 // keeps Tab as a literal no-op.
4800 //4819 //
4801 // Free function (not a method) so the same logic runs against the4820 // Free function (not a method) so the same logic runs against the
4802-// editor instances owned by btwDialog and swarmDialog without each4821+// editor instances owned by btwDialog and subagentsDialog without each
4803 // dialog needing its own copy.4822 // dialog needing its own copy.
4804 func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {4823 func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
4805 if ed == nil {4824 if ed == nil {
@@ -5210,8 +5229,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5210 break5229 break
5211 }5230 }
5212 i.openSessionOpsDialog()5231 i.openSessionOpsDialog()
5213- case "/swarm":5232+ case "/subagents":
5214- i.runSwarm(ctx, parts[1:])5233+ i.runSubagents(ctx, parts[1:])
5215 default:5234 default:
5216 // Last-resort fallback: try the extension manager. Built-in5235 // Last-resort fallback: try the extension manager. Built-in
5217 // cases above always win; this branch only fires for slash5236 // cases above always win; this branch only fires for slash
@@ -5411,7 +5430,7 @@ func (i *Interactive) doLogout(target string) {
5411 // it so prompts can't go out with the stale client, and hint at5430 // it so prompts can't go out with the stale client, and hint at
5412 // /login. Close any LSP processes owned by its tools first.5431 // /login. Close any LSP processes owned by its tools first.
5413 if i.agent != nil {5432 if i.agent != nil {
5414- _ = tools.CloseLSPManagers(i.agent.Tools)5433+ _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
5415 }5434 }
5416 i.agent = nil5435 i.agent = nil
5417 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."5436 i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
@@ -5621,14 +5640,29 @@ func (i *Interactive) cancelAndWaitForIdle() {
5621 // submitted as the first question, so '/btw does X work?' fires the5640 // submitted as the first question, so '/btw does X work?' fires the
5622 // model call immediately instead of just opening an empty dialog.5641 // model call immediately instead of just opening an empty dialog.
5623 func (i *Interactive) openBtwDialog(args []string) {5642 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 {
5625 i.mu.Lock()5659 i.mu.Lock()
5626 i.statusErr = "not logged in. type /login first."5660 i.statusErr = "not logged in. type /login first."
5627 i.mu.Unlock()5661 i.mu.Unlock()
5628 return5662 return
5629 }5663 }
5630 seed := strings.TrimSpace(strings.Join(args, " "))5664 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)
5632 i.invalidate()5666 i.invalidate()
5633 }5667 }
5634 5668
@@ -6005,7 +6039,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
6005 ag.SeedCost(carryCost)6039 ag.SeedCost(carryCost)
6006 6040
6007 if i.agent != nil && i.agent != ag {6041 if i.agent != nil && i.agent != ag {
6008- _ = tools.CloseLSPManagers(i.agent.Tools)6042+ _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6009 }6043 }
6010 i.mu.Lock()6044 i.mu.Lock()
6011 i.agent = ag6045 i.agent = ag
@@ -6025,7 +6059,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
6025 // dynamically-registered tools need to be reattached. The apply6059 // dynamically-registered tools need to be reattached. The apply
6026 // helpers are no-ops when their feature is inactive, so the6060 // helpers are no-ops when their feature is inactive, so the
6027 // cross-provider path still works on a vanilla setup.6061 // cross-provider path still works on a vanilla setup.
6028- i.applyAutoSwarmTool(i.autoSwarmEnabled())6062+ i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6029 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())6063 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
6030 if i.cfg.PersistModel != nil {6064 if i.cfg.PersistModel != nil {
6031 i.cfg.PersistModel(p, md)6065 i.cfg.PersistModel(p, md)
@@ -6057,9 +6091,9 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
6057 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method6091 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
6058 i.mu.Unlock()6092 i.mu.Unlock()
6059 if oldAgent != nil {6093 if oldAgent != nil {
6060- _ = tools.CloseLSPManagers(oldAgent.Tools)6094+ _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
6061 }6095 }
6062- i.applyAutoSwarmTool(i.autoSwarmEnabled())6096+ i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6063 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())6097 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
6064 // Authentication can change the provider used by the live6098 // Authentication can change the provider used by the live
6065 // agent. Persist it on the active session just like /model.6099 // agent. Persist it on the active session just like /model.
@@ -7205,95 +7239,95 @@ func (a telegramSenderAdapter) Active() bool {
7205 return a.bridge != nil && a.bridge.Active()7239 return a.bridge != nil && a.bridge.Active()
7206 }7240 }
7207 7241
7208-// swarmWatchEntry is one tracked auto-swarm sub-agent. It records the7242+// subagentWatchEntry is one tracked auto-subagents sub-agent. It records the
7209 // delegated turn's outcome separately from the long-lived daemon status.7243 // delegated turn's outcome separately from the long-lived daemon status.
7210-type swarmWatchEntry struct {7244+type subagentWatchEntry struct {
7211- agent *swarm.Agent7245+ agent *subagents.Agent
7212 task string7246 task string
7213 done bool7247 done bool
7214 outcome string7248 outcome string
7215 err string7249 err string
7216 }7250 }
7217 7251
7218-// TrackSwarmAgent is the exported entry point used by the cli to7252+// TrackSubagentWorker is the exported entry point used by the cli to
7219-// hand a freshly-spawned auto-swarm agent off to the watcher.7253+// hand a freshly-spawned auto-subagents agent off to the watcher.
7220-func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {7254+func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {
7221- i.trackSwarmAgent(a, task)7255+ i.trackSubagentWorker(a, task)
7222 }7256 }
7223 7257
7224-// trackSwarmAgent records a freshly-spawned auto-swarm agent and7258+// trackSubagentWorker records a freshly-spawned auto-subagents agent and
7225 // subscribes to both ways its delegated task can finish. Successful7259 // subscribes to both ways its delegated task can finish. Successful
7226 // long-lived agents report a prompt-level turn_end and keep listening;7260 // long-lived agents report a prompt-level turn_end and keep listening;
7227 // startup failures and unexpected daemon exits only unblock Agent.Wait.7261 // startup failures and unexpected daemon exits only unblock Agent.Wait.
7228 // Whichever signal arrives first completes the entry exactly once.7262 // Whichever signal arrives first completes the entry exactly once.
7229 //7263 //
7230-// Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-7264+// Wired in from cli.go via SubagentSpawnTool.OnSpawned only when auto-
7231-// swarm is enabled, so this is a no-op when the feature is off.7265+// subagent is enabled, so this is a no-op when the feature is off.
7232-func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) {7266+func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) {
7233 if i == nil || a == nil {7267 if i == nil || a == nil {
7234 return7268 return
7235 }7269 }
7236- entry := &swarmWatchEntry{agent: a, task: task}7270+ entry := &subagentWatchEntry{agent: a, task: task}
7237- i.swarmWatchMu.Lock()7271+ i.subagentWatchMu.Lock()
7238- i.swarmWatch = append(i.swarmWatch, entry)7272+ i.subagentWatch = append(i.subagentWatch, entry)
7239- i.swarmWatchMu.Unlock()7273+ i.subagentWatchMu.Unlock()
7240 7274
7241 a.SetOnTurnEnd(func(step int, errMsg string) {7275 a.SetOnTurnEnd(func(step int, errMsg string) {
7242 outcome := "completed"7276 outcome := "completed"
7243 if errMsg != "" {7277 if errMsg != "" {
7244 outcome = "failed"7278 outcome = "failed"
7245 }7279 }
7246- i.completeSwarmWatchEntry(entry, outcome, errMsg)7280+ i.completeSupervisorWatchEntry(entry, outcome, errMsg)
7247 })7281 })
7248 go func() {7282 go func() {
7249 a.Wait()7283 a.Wait()
7250 snap := a.Snapshot()7284 snap := a.Snapshot()
7251 outcome := string(snap.Status)7285 outcome := string(snap.Status)
7252- if snap.Status == swarm.StatusDone {7286+ if snap.Status == subagents.StatusDone {
7253 outcome = "completed"7287 outcome = "completed"
7254 }7288 }
7255- i.completeSwarmWatchEntry(entry, outcome, snap.Err)7289+ i.completeSupervisorWatchEntry(entry, outcome, snap.Err)
7256 }()7290 }()
7257 }7291 }
7258 7292
7259-func (i *Interactive) completeSwarmWatchEntry(entry *swarmWatchEntry, outcome, errMsg string) {7293+func (i *Interactive) completeSupervisorWatchEntry(entry *subagentWatchEntry, outcome, errMsg string) {
7260- i.swarmWatchMu.Lock()7294+ i.subagentWatchMu.Lock()
7261 if entry.done {7295 if entry.done {
7262- i.swarmWatchMu.Unlock()7296+ i.subagentWatchMu.Unlock()
7263 return7297 return
7264 }7298 }
7265 entry.done = true7299 entry.done = true
7266 entry.outcome = outcome7300 entry.outcome = outcome
7267 entry.err = errMsg7301 entry.err = errMsg
7268 allDone := true7302 allDone := true
7269- for _, e := range i.swarmWatch {7303+ for _, e := range i.subagentWatch {
7270 if !e.done {7304 if !e.done {
7271 allDone = false7305 allDone = false
7272 break7306 break
7273 }7307 }
7274 }7308 }
7275- var batch []*swarmWatchEntry7309+ var batch []*subagentWatchEntry
7276 if allDone {7310 if allDone {
7277- batch = i.swarmWatch7311+ batch = i.subagentWatch
7278- i.swarmWatch = nil7312+ i.subagentWatch = nil
7279 }7313 }
7280- i.swarmWatchMu.Unlock()7314+ i.subagentWatchMu.Unlock()
7281 if len(batch) != 0 {7315 if len(batch) != 0 {
7282- i.flushSwarmSummary(batch)7316+ i.flushSupervisorSummary(batch)
7283 }7317 }
7284 }7318 }
7285 7319
7286-// flushSwarmSummary composes a synthetic user turn describing every7320+// flushSupervisorSummary composes a synthetic user turn describing every
7287 // sub-agent's outcome and injects it via SubmitOrQueue so the main7321 // sub-agent's outcome and injects it via SubmitOrQueue so the main
7288 // agent picks it up at the next safe boundary. The summary is7322 // agent picks it up at the next safe boundary. The summary is
7289-// phrased as a system update ("Auto-swarm finished: ...") so the7323+// phrased as a system update ("Auto-subagents finished: ...") so the
7290 // model treats it as observed state, not as a fresh user request.7324 // 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) {
7292 if len(batch) == 0 {7326 if len(batch) == 0 {
7293 return7327 return
7294 }7328 }
7295 var sb strings.Builder7329 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))
7297 for idx, e := range batch {7331 for idx, e := range batch {
7298 snap := e.agent.Snapshot()7332 snap := e.agent.Snapshot()
7299 status := e.outcome7333 status := e.outcome
@@ -7332,20 +7366,20 @@ func truncateForSummary(s string, n int) string {
7332 return s[:n-3] + "..."7366 return s[:n-3] + "..."
7333 }7367 }
7334 7368
7335-// autoSwarmAddenda returns the prompt blocks owned by the auto-swarm7369+// autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents
7336 // toggle, in the same order Resolve appends them to a new agent.7370 // toggle, in the same order Resolve appends them to a new agent.
7337-func autoSwarmAddenda(cfg InteractiveConfig) []string {7371+func autoSubagentsAddenda(cfg InteractiveConfig) []string {
7338 addenda := make([]string, 0, 2)7372 addenda := make([]string, 0, 2)
7339 if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {7373 if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
7340 addenda = append(addenda, addendum)7374 addenda = append(addenda, addendum)
7341 }7375 }
7342- if addendum := strings.TrimSpace(cfg.AutoSwarmSystemAddendum); addendum != "" {7376+ if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
7343 addenda = append(addenda, addendum)7377 addenda = append(addenda, addendum)
7344 }7378 }
7345 return addenda7379 return addenda
7346 }7380 }
7347 7381
7348-func containsAutoSwarmAddendum(addenda []string, want string) bool {7382+func containsAutoSubagentsAddendum(addenda []string, want string) bool {
7349 for _, addendum := range addenda {7383 for _, addendum := range addenda {
7350 if addendum == want {7384 if addendum == want {
7351 return true7385 return true
@@ -7354,10 +7388,10 @@ func containsAutoSwarmAddendum(addenda []string, want string) bool {
7354 return false7388 return false
7355 }7389 }
7356 7390
7357-// removeLastAutoSwarmAddendum removes one occurrence of a block known to7391+// removeLastAutoSubagentsAddendum removes one occurrence of a block known to
7358-// have been appended by auto-swarm. Resolve appends owned blocks at the7392+// have been appended by auto-subagents. Resolve appends owned blocks at the
7359 // end, so removing the last occurrence preserves an identical base block.7393 // 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) {
7361 idx := strings.LastIndex(system, addendum)7395 idx := strings.LastIndex(system, addendum)
7362 if idx < 0 {7396 if idx < 0 {
7363 return system, false7397 return system, false
@@ -7365,75 +7399,110 @@ func removeLastAutoSwarmAddendum(system, addendum string) (string, bool) {
7365 return system[:idx] + system[idx+len(addendum):], true7399 return system[:idx] + system[idx+len(addendum):], true
7366 }7400 }
7367 7401
7368-// applyAutoSwarmSystemPrompt appends (active=true) or strips7402+// applyAutoSubagentsSystemPrompt appends (active=true) or strips
7369-// (active=false) the auto-swarm prompt blocks on the running agent.7403+// (active=false) the auto-subagents prompt blocks on the running agent.
7370 // The profile manifest and delegation guidance are managed together so7404 // The profile manifest and delegation guidance are managed together so
7371-// toggling auto-swarm never leaves the model with names but no tool.7405+// toggling auto-subagents never leaves the model with names but no tool.
7372-func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {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()
7373 if i.agent == nil {7412 if i.agent == nil {
7374 return7413 return
7375 }7414 }
7376- addenda := autoSwarmAddenda(i.cfg)7415+ addenda := autoSubagentsAddenda(i.cfg)
7377 if active {7416 if active {
7378- sys := i.agent.System7417+ sys, _ := i.agent.PromptConfig()
7379 for _, addendum := range addenda {7418 for _, addendum := range addenda {
7380- if containsAutoSwarmAddendum(i.managedAutoSwarmAddenda, addendum) {7419+ if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) {
7381 continue7420 continue
7382 }7421 }
7383 if sys != "" && !strings.HasSuffix(sys, "\n\n") {7422 if sys != "" && !strings.HasSuffix(sys, "\n\n") {
7384 sys += "\n\n"7423 sys += "\n\n"
7385 }7424 }
7386 sys += addendum7425 sys += addendum
7387- i.managedAutoSwarmAddenda = append(i.managedAutoSwarmAddenda, addendum)7426+ i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
7388 }7427 }
7389- i.agent.System = sys7428+ i.agent.SetSystemPrompt(sys)
7390 return7429 return
7391 }7430 }
7392 7431
7393- sys := i.agent.System7432+ sys, _ := i.agent.PromptConfig()
7394 changed := false7433 changed := false
7395- for idx := len(i.managedAutoSwarmAddenda) - 1; idx >= 0; idx-- {7434+ for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
7396 var removed bool7435 var removed bool
7397- sys, removed = removeLastAutoSwarmAddendum(sys, i.managedAutoSwarmAddenda[idx])7436+ sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
7398 changed = changed || removed7437 changed = changed || removed
7399 }7438 }
7400- i.managedAutoSwarmAddenda = nil7439+ i.managedAutoSubagentsAddenda = nil
7401 if changed {7440 if changed {
7402- i.agent.System = strings.TrimRight(sys, "\n") + "\n"7441+ i.agent.SetSystemPrompt(strings.TrimRight(sys, "\n") + "\n")
7403 }7442 }
7404 }7443 }
7405 7444
7406-// applyAutoSwarmTool registers (active=true) or removes (active=false)7445+// applyAutoSubagentsTool registers (active=true) or removes (active=false)
7407-// the swarm_spawn tool on the running agent so the model only sees it7446+// the subagent_spawn tool on the running agent so the model only sees it
7408-// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'7447+// when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'
7409 // snapshot+mutate pattern so extension tools and /reload-ext additions7448 // snapshot+mutate pattern so extension tools and /reload-ext additions
7410 // survive a toggle.7449 // survive a toggle.
7411-func (i *Interactive) autoSwarmEnabled() bool {7450+func (i *Interactive) autoSubagentsAvailable() bool {
7412- return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled7451+ 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, "; ")
7413 }7463 }
7414 7464
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()
7416 if i.agent == nil {7484 if i.agent == nil {
7417 return7485 return
7418 }7486 }
7419- current := i.agent.Tools7487+ current := i.agent.ToolsSnapshot()
7420 next := core.Registry{}7488 next := core.Registry{}
7421 for name, t := range current {7489 for name, t := range current {
7422- if name == "swarm_spawn" {7490+ if name == "subagent_spawn" {
7423 continue7491 continue
7424 }7492 }
7425 next[name] = t7493 next[name] = t
7426 }7494 }
7427- if active && i.cfg.Swarm != nil {7495+ if active && i.autoSubagentsAvailable() {
7428- next["swarm_spawn"] = &tools.SwarmSpawnTool{7496+ canonical := &tools.SubagentSpawnTool{
7429- Swarm: i.cfg.Swarm,7497+ Supervisor: i.cfg.Supervisor,
7430- Enabled: func() bool { return true },7498+ Enabled: func() bool { return i.autoSubagentsEnabled() },
7431 DefaultModel: func() string { return i.cfg.Model },7499 DefaultModel: func() string { return i.cfg.Model },
7432 DefaultProvider: func() string { return i.cfg.Provider },7500 DefaultProvider: func() string { return i.cfg.Provider },
7433 DefaultReasoning: func() string { return i.cfg.Reasoning },7501 DefaultReasoning: func() string { return i.cfg.Reasoning },
7434 ResolveSubagent: i.cfg.ResolveSubagent,7502 ResolveSubagent: i.cfg.ResolveSubagent,
7435- OnSpawned: i.trackSwarmAgent,7503+ OnSpawned: i.trackSubagentWorker,
7436 }7504 }
7505+ next[canonical.Name()] = canonical
7437 }7506 }
7438 i.agent.SetTools(next)7507 i.agent.SetTools(next)
7439 }7508 }
@@ -7446,10 +7515,14 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
7446 // later /telegram disconnect (we only add or strip the two telegram7515 // later /telegram disconnect (we only add or strip the two telegram
7447 // entries, never the rest).7516 // entries, never the rest).
7448 func (i *Interactive) applyTelegramTools(active bool) {7517 func (i *Interactive) applyTelegramTools(active bool) {
7518+ i.agentMu.Lock()
7519+ defer i.agentMu.Unlock()
7520+ i.mu.Lock()
7521+ defer i.mu.Unlock()
7449 if i.agent == nil {7522 if i.agent == nil {
7450 return7523 return
7451 }7524 }
7452- current := i.agent.Tools7525+ current := i.agent.ToolsSnapshot()
7453 next := core.Registry{}7526 next := core.Registry{}
7454 for name, t := range current {7527 for name, t := range current {
7455 if name == "telegram_send_image" || name == "telegram_send_file" {7528 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{
62 {Name: "/jail", Desc: "confine tools to the current directory"},62 {Name: "/jail", Desc: "confine tools to the current directory"},
63 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},63 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},
64 {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"},64 {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"},
66 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},66 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
67 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},67 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
68 {Name: "/settings", Desc: "open settings"},68 {Name: "/settings", Desc: "open settings"},
M packages/agent/modes/slash_suggest_test.go +3 -3
@@ -37,10 +37,10 @@ func TestSlashSuggesterShowsLlamaOnlyWhenConfigured(t *testing.T) {
37 }37 }
38 }38 }
39 39
40-func TestSlashSuggesterHasSwarm(t *testing.T) {40+func TestSlashSuggesterHasSupervisor(t *testing.T) {
41 s := newSlashSuggester()41 s := newSlashSuggester()
42- if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") {42+ if got := commandNames(s.matches("/su")); !contains(got, "/subagents") {
43- t.Fatalf("/swarm missing from suggestions, got %v", got)43+ t.Fatalf("/subagents missing from suggestions, got %v", got)
44 }44 }
45 }45 }
46 46
R packages/agent/modes/swarm_dialog.go → packages/agent/modes/subagents_dialog.go +97 -94 renamed, 88% similar
@@ -6,13 +6,13 @@ import (
6 "strings"6 "strings"
7 "time"7 "time"
8 8
9- "github.com/patriceckhart/zot/packages/agent/swarm"9+ "github.com/patriceckhart/zot/packages/agent/subagents"
10 "github.com/patriceckhart/zot/packages/tui"10 "github.com/patriceckhart/zot/packages/tui"
11 )11 )
12 12
13-// swarmDialog is the dashboard shown by /swarm (no argument) and13+// subagentsDialog is the dashboard shown by /subagents (no argument) and
14-// /swarm list. It's a read-only window onto a *swarm.Swarm; the14+// /subagents list. It's a read-only window onto a *subagents.Supervisor; the
15-// caller passes a snapshotter so the dialog never holds the swarm15+// caller passes a snapshotter so the dialog never holds the subagent
16 // pointer or its lock.16 // pointer or its lock.
17 //17 //
18 // Keys (list view):18 // Keys (list view):
@@ -31,16 +31,16 @@ import (
31 // p send a follow-up prompt to this agent (inline editor)31 // p send a follow-up prompt to this agent (inline editor)
32 // ↑/↓/PgUp/PgDn/Home/End scroll32 // ↑/↓/PgUp/PgDn/Home/End scroll
33 // esc / q return to the list33 // esc / q return to the list
34-type swarmDialog struct {34+type subagentsDialog struct {
35 active bool35 active bool
36 compactMode bool36 compactMode bool
37 lineInput bool37 lineInput bool
38- snapshot func() []swarm.AgentSnapshot38+ snapshot func() []subagents.AgentSnapshot
39 stop func(id string) error39 stop func(id string) error
40 remove func(id string) error40 remove func(id string) error
41 // spawn accepts an optional model + provider override (empty41 // spawn accepts an optional model + provider override (empty
42 // strings mean "let the child resolve its own default"). The cli42 // 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.
44 spawn func(task, model, provider string) error44 spawn func(task, model, provider string) error
45 // send delivers a follow-up user turn to a running agent's inbox.45 // send delivers a follow-up user turn to a running agent's inbox.
46 // Wired by Open(); when nil the inline 'p' shortcut is disabled.46 // Wired by Open(); when nil the inline 'p' shortcut is disabled.
@@ -50,7 +50,7 @@ type swarmDialog struct {
50 // disabled.50 // disabled.
51 resume func(id string) error51 resume func(id string) error
52 52
53- rows []swarm.AgentSnapshot53+ rows []subagents.AgentSnapshot
54 cursor int54 cursor int
55 viewing bool // when true, show full transcript of the selected row55 viewing bool // when true, show full transcript of the selected row
56 56
@@ -63,7 +63,7 @@ type swarmDialog struct {
63 transcriptSpin *spinner63 transcriptSpin *spinner
64 64
65 // spawning toggles the inline "enter task" editor. While true the65 // spawning toggles the inline "enter task" editor. While true the
66- // dialog captures keys into a real tui.Editor so swarm-new has the66+ // dialog captures keys into a real tui.Editor so subagent-new has the
67 // same editing, paste, drag-drop file chips, and visual style as67 // same editing, paste, drag-drop file chips, and visual style as
68 // the default input and /btw input.68 // the default input and /btw input.
69 spawning bool69 spawning bool
@@ -74,7 +74,7 @@ type swarmDialog struct {
74 // pendingModel / pendingProvider are the model + provider every74 // pendingModel / pendingProvider are the model + provider every
75 // freshly-spawned agent will be pinned to. They're seeded from75 // freshly-spawned agent will be pinned to. They're seeded from
76 // the host's currently-active model (via SetCurrentModel) at the76 // the host's currently-active model (via SetCurrentModel) at the
77- // moment the user opens the dashboard, so swarm agents inherit77+ // moment the user opens the dashboard, so subagent agents inherit
78 // whatever the user last picked via /model. Empty means "don't78 // whatever the user last picked via /model. Empty means "don't
79 // pin a model; let the child resolve its own default".79 // pin a model; let the child resolve its own default".
80 pendingModel string80 pendingModel string
@@ -98,7 +98,7 @@ type swarmDialog struct {
98 98
99 // prompting toggles the inline "send prompt to selected agent"99 // prompting toggles the inline "send prompt to selected agent"
100 // editor. Distinct from `spawning` so we don't accidentally100 // editor. Distinct from `spawning` so we don't accidentally
101- // route a follow-up into Swarm.Spawn. The editor and its101+ // route a follow-up into Supervisor.Spawn. The editor and its
102 // file-suggest live in their own fields so opening the prompt102 // file-suggest live in their own fields so opening the prompt
103 // editor while a spawn editor is half-typed doesn't clobber it103 // editor while a spawn editor is half-typed doesn't clobber it
104 // (the dialog enforces one-at-a-time, but the separate state104 // (the dialog enforces one-at-a-time, but the separate state
@@ -113,30 +113,30 @@ type swarmDialog struct {
113 promptTargetID string113 promptTargetID string
114 }114 }
115 115
116-func newSwarmDialog() *swarmDialog { return &swarmDialog{} }116+func newSubagentsDialog() *subagentsDialog { return &subagentsDialog{} }
117 117
118-// SetCurrentModel pins the model + provider every fresh spawn will118+func (d *subagentsDialog) SetCompactMode(enabled bool) {
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) {
125 d.compactMode = enabled119 d.compactMode = enabled
126 }120 }
127 121
128-func (d *swarmDialog) SetLineInput(enabled bool) {122+func (d *subagentsDialog) SetLineInput(enabled bool) {
129 d.lineInput = enabled123 d.lineInput = enabled
130 }124 }
131 125
132-func (d *swarmDialog) editorPrompt(th tui.Theme) string {126+func (d *subagentsDialog) editorPrompt(th tui.Theme) string {
133 if d.lineInput {127 if d.lineInput {
134 return ""128 return ""
135 }129 }
136 return th.AccentBar(th.Accent)130 return th.AccentBar(th.Accent)
137 }131 }
138 132
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) {
140 d.pendingModel = model140 d.pendingModel = model
141 d.pendingProvider = providerID141 d.pendingProvider = providerID
142 }142 }
@@ -146,16 +146,16 @@ func (d *swarmDialog) SetCurrentModel(model, providerID string) {
146 // the user invokes /model inside the spawn editor so the picker can146 // the user invokes /model inside the spawn editor so the picker can
147 // filter out unauthenticated providers, matching the global /model147 // filter out unauthenticated providers, matching the global /model
148 // behaviour.148 // behaviour.
149-func (d *swarmDialog) SetLoggedInProviders(provs []string) {149+func (d *subagentsDialog) SetLoggedInProviders(provs []string) {
150 d.modelPickerLoggedIn = provs150 d.modelPickerLoggedIn = provs
151 }151 }
152 152
153-// Open binds the dialog to a swarm via the provided callbacks. The153+// Open binds the dialog to a subagent via the provided callbacks. The
154 // dialog refreshes the snapshot every Render so the dashboard reads154 // dialog refreshes the snapshot every Render so the dashboard reads
155 // live state without needing a goroutine. spawn / send are optional;155 // live state without needing a goroutine. spawn / send are optional;
156 // when nil the corresponding inline shortcut ('n' / 'p') is disabled.156 // when nil the corresponding inline shortcut ('n' / 'p') is disabled.
157-func (d *swarmDialog) Open(157+func (d *subagentsDialog) Open(
158- snapshot func() []swarm.AgentSnapshot,158+ snapshot func() []subagents.AgentSnapshot,
159 stop func(id string) error,159 stop func(id string) error,
160 remove func(id string) error,160 remove func(id string) error,
161 spawn func(task, model, provider string) error,161 spawn func(task, model, provider string) error,
@@ -194,7 +194,7 @@ func (d *swarmDialog) Open(
194 // CursorPos returns the row/col for the terminal cursor while an194 // CursorPos returns the row/col for the terminal cursor while an
195 // inline editor (spawn or prompt) is active so the caret blinks in195 // inline editor (spawn or prompt) is active so the caret blinks in
196 // the right spot. Returns -1, -1 when no input is being captured.196 // 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) {
198 if !d.Active() {198 if !d.Active() {
199 return -1, -1199 return -1, -1
200 }200 }
@@ -264,7 +264,7 @@ func (d *swarmDialog) CursorPos(width int) (row, col int) {
264 // belongs on when the always-on editor sits at the bottom of the264 // belongs on when the always-on editor sits at the bottom of the
265 // transcript view. Mirrors renderTranscript's row layout so the265 // transcript view. Mirrors renderTranscript's row layout so the
266 // terminal cursor stays glued to the editor as the body grows.266 // 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 {
268 a := d.selected()268 a := d.selected()
269 if a == nil {269 if a == nil {
270 return -1270 return -1
@@ -299,7 +299,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse
299 // Full body, no scroll window — the host's outer scrollback299 // Full body, no scroll window — the host's outer scrollback
300 // owns overflow now. Cursor row math just counts every300 // owns overflow now. Cursor row math just counts every
301 // rendered line.301 // rendered line.
302- body := renderSwarmTranscriptBlocks(raw, tui.Theme{}, width, d.compactMode)302+ body := renderSupervisorTranscriptBlocks(raw, tui.Theme{}, width, d.compactMode)
303 row += len(body)303 row += len(body)
304 }304 }
305 305
@@ -311,8 +311,8 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse
311 return row + editorRowOffset311 return row + editorRowOffset
312 }312 }
313 313
314-func (d *swarmDialog) Active() bool { return d != nil && d.active }314+func (d *subagentsDialog) Active() bool { return d != nil && d.active }
315-func (d *swarmDialog) Close() { d.active = false }315+func (d *subagentsDialog) Close() { d.active = false }
316 316
317 // NeedsTickRefresh reports whether the host's animation tick should317 // NeedsTickRefresh reports whether the host's animation tick should
318 // repaint the screen while this dialog is open. True when the318 // repaint the screen while this dialog is open. True when the
@@ -327,7 +327,7 @@ func (d *swarmDialog) Close() { d.active = false }
327 // tick refresh. The cursor blink still works because the tick rate327 // tick refresh. The cursor blink still works because the tick rate
328 // is well below the terminal's blink rate, and we need the redraws328 // is well below the terminal's blink rate, and we need the redraws
329 // for streaming output. /btw makes the same trade-off.329 // for streaming output. /btw makes the same trade-off.
330-func (d *swarmDialog) NeedsTickRefresh() bool {330+func (d *subagentsDialog) NeedsTickRefresh() bool {
331 if d == nil || !d.active {331 if d == nil || !d.active {
332 return false332 return false
333 }333 }
@@ -345,12 +345,12 @@ func (d *swarmDialog) NeedsTickRefresh() bool {
345 }345 }
346 346
347 // OpenViewing opens the dashboard and jumps straight to the347 // OpenViewing opens the dashboard and jumps straight to the
348-// transcript view for the agent with id agentID. Used by /swarm logs348+// transcript view for the agent with id agentID. Used by /subagents logs
349 // <id>. If no such agent is found the dashboard opens normally so349 // <id>. If no such agent is found the dashboard opens normally so
350 // the user can see why (and pick from the list).350 // the user can see why (and pick from the list).
351-func (d *swarmDialog) OpenViewing(351+func (d *subagentsDialog) OpenViewing(
352 agentID string,352 agentID string,
353- snapshot func() []swarm.AgentSnapshot,353+ snapshot func() []subagents.AgentSnapshot,
354 stop func(id string) error,354 stop func(id string) error,
355 remove func(id string) error,355 remove func(id string) error,
356 spawn func(task, model, provider string) error,356 spawn func(task, model, provider string) error,
@@ -365,9 +365,9 @@ func (d *swarmDialog) OpenViewing(
365 d.cursor = i365 d.cursor = i
366 d.viewing = true366 d.viewing = true
367 // Auto-open the inline editor for running agents so367 // Auto-open the inline editor for running agents so
368- // /swarm logs <id> drops the user straight into a368+ // /subagents logs <id> drops the user straight into a
369 // /btw-style send-ready prompt.369 // /btw-style send-ready prompt.
370- if r.Status == swarm.StatusRunning && d.send != nil {370+ if r.Status == subagents.StatusRunning && d.send != nil {
371 d.openPromptEditor(tui.Theme{}, r.ID)371 d.openPromptEditor(tui.Theme{}, r.ID)
372 }372 }
373 return true373 return true
@@ -386,8 +386,8 @@ func (d *swarmDialog) OpenViewing(
386 // so the user has context: pressing ↑/↓ they can still walk through386 // so the user has context: pressing ↑/↓ they can still walk through
387 // running agents, and the R key is a no-op there (Resume rejects387 // running agents, and the R key is a no-op there (Resume rejects
388 // non-terminal agents with a clear error).388 // non-terminal agents with a clear error).
389-func (d *swarmDialog) OpenForResume(389+func (d *subagentsDialog) OpenForResume(
390- snapshot func() []swarm.AgentSnapshot,390+ snapshot func() []subagents.AgentSnapshot,
391 stop func(id string) error,391 stop func(id string) error,
392 remove func(id string) error,392 remove func(id string) error,
393 spawn func(task, model, provider string) error,393 spawn func(task, model, provider string) error,
@@ -410,14 +410,14 @@ func (d *swarmDialog) OpenForResume(
410 }410 }
411 411
412 // isResumable reports whether a Status is eligible for Resume. Mirror412 // isResumable reports whether a Status is eligible for Resume. Mirror
413-// of the precondition inside Swarm.Resume; centralised so the dialog413+// of the precondition inside Supervisor.Resume; centralised so the dialog
414 // and the slash handler agree on what "resumable" means.414 // and the slash handler agree on what "resumable" means.
415-func isResumable(s swarm.Status) bool {415+func isResumable(s subagents.Status) bool {
416 switch s {416 switch s {
417- case swarm.StatusDetached,417+ case subagents.StatusDetached,
418- swarm.StatusDone,418+ subagents.StatusDone,
419- swarm.StatusFailed,419+ subagents.StatusFailed,
420- swarm.StatusKilled:420+ subagents.StatusKilled:
421 return true421 return true
422 }422 }
423 return false423 return false
@@ -426,8 +426,8 @@ func isResumable(s swarm.Status) bool {
426 // promptDisabledHint returns the status-bar message shown when the426 // promptDisabledHint returns the status-bar message shown when the
427 // user presses 'p' on an agent that can't receive input. We branch on427 // user presses 'p' on an agent that can't receive input. We branch on
428 // status so the hint points at the right next step: resume for the428 // status so the hint points at the right next step: resume for the
429-// states Swarm.Resume accepts; nothing useful otherwise.429+// states Supervisor.Resume accepts; nothing useful otherwise.
430-func promptDisabledHint(s swarm.Status) string {430+func promptDisabledHint(s subagents.Status) string {
431 if isResumable(s) {431 if isResumable(s) {
432 return "prompt: agent is " + string(s) + "; press R to resume first"432 return "prompt: agent is " + string(s) + "; press R to resume first"
433 }433 }
@@ -438,24 +438,24 @@ func promptDisabledHint(s swarm.Status) string {
438 // Kill only makes sense on running / pending agents; on detached and438 // Kill only makes sense on running / pending agents; on detached and
439 // terminal ones it's a no-op and the user usually wants 'r' (remove)439 // terminal ones it's a no-op and the user usually wants 'r' (remove)
440 // to clear out the agent's state instead.440 // to clear out the agent's state instead.
441-func killDisabledHint(s swarm.Status) string {441+func killDisabledHint(s subagents.Status) string {
442 return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)"442 return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)"
443 }443 }
444 444
445 // friendlySendErr turns a raw send error into a status-bar message445 // friendlySendErr turns a raw send error into a status-bar message
446 // the user can act on. The most common failure mode — dialling an446 // the user can act on. The most common failure mode — dialling an
447 // agent whose previous subprocess exited — surfaces as447 // agent whose previous subprocess exited — surfaces as
448-// swarm.ErrNotReady; we rewrite it to mention resume instead of448+// subagents.ErrNotReady; we rewrite it to mention resume instead of
449 // leaking the unix-socket path. Other errors fall through verbatim449 // leaking the unix-socket path. Other errors fall through verbatim
450 // because they're rare and almost always indicate a real bug.450 // because they're rare and almost always indicate a real bug.
451 func friendlySendErr(id string, err error) string {451 func friendlySendErr(id string, err error) string {
452- if errors.Is(err, swarm.ErrNotReady) {452+ if errors.Is(err, subagents.ErrNotReady) {
453 return "send: agent " + id + " isn't accepting input (press R to resume)"453 return "send: agent " + id + " isn't accepting input (press R to resume)"
454 }454 }
455 return "send: " + err.Error()455 return "send: " + err.Error()
456 }456 }
457 457
458-func (d *swarmDialog) refresh() {458+func (d *subagentsDialog) refresh() {
459 if d.snapshot == nil {459 if d.snapshot == nil {
460 d.rows = nil460 d.rows = nil
461 return461 return
@@ -472,7 +472,7 @@ func (d *swarmDialog) refresh() {
472 // HandleKey advances state. Returns (closed, statusMsg, statusErr).472 // HandleKey advances state. Returns (closed, statusMsg, statusErr).
473 // statusMsg / statusErr surface in the host's status bar so the user473 // statusMsg / statusErr surface in the host's status bar so the user
474 // gets feedback on kill/remove without writing to the chat.474 // 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) {
476 if !d.Active() {476 if !d.Active() {
477 return false, "", ""477 return false, "", ""
478 }478 }
@@ -519,7 +519,7 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
519 // can start typing immediately. Skipped for non-running519 // can start typing immediately. Skipped for non-running
520 // agents; their inbox isn't listening, so an editor520 // agents; their inbox isn't listening, so an editor
521 // would just frustrate them with bounced sends.521 // 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 {
523 d.openPromptEditor(tui.Theme{}, a.ID)523 d.openPromptEditor(tui.Theme{}, a.ID)
524 }524 }
525 }525 }
@@ -534,7 +534,7 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
534 // agent will run against was already captured via534 // agent will run against was already captured via
535 // SetCurrentModel from the host's /model selection;535 // SetCurrentModel from the host's /model selection;
536 // the user changes models the normal way (/model)536 // the user changes models the normal way (/model)
537- // before opening /swarm.537+ // before opening /subagents.
538 d.openSpawnEditor(tui.Theme{})538 d.openSpawnEditor(tui.Theme{})
539 }539 }
540 case 'p':540 case 'p':
@@ -544,7 +544,7 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
544 // failed / killed agent fails with a long ECONNREFUSED544 // failed / killed agent fails with a long ECONNREFUSED
545 // or ENOENT trail that's not actionable. Tell the user545 // or ENOENT trail that's not actionable. Tell the user
546 // what to do instead.546 // what to do instead.
547- if a.Status != swarm.StatusRunning {547+ if a.Status != subagents.StatusRunning {
548 return false, "", promptDisabledHint(a.Status)548 return false, "", promptDisabledHint(a.Status)
549 }549 }
550 d.openPromptEditor(tui.Theme{}, a.ID)550 d.openPromptEditor(tui.Theme{}, a.ID)
@@ -565,10 +565,10 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
565 if a := d.selected(); a != nil && d.stop != nil {565 if a := d.selected(); a != nil && d.stop != nil {
566 // Only running / pending agents have something to566 // Only running / pending agents have something to
567 // cancel. Detached and already-terminal agents would567 // cancel. Detached and already-terminal agents would
568- // be silent no-ops at the Swarm layer; surface a hint568+ // be silent no-ops at the Supervisor layer; surface a hint
569 // so the user knows kill did nothing and what to do569 // so the user knows kill did nothing and what to do
570 // instead (remove via 'r').570 // instead (remove via 'r').
571- if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending {571+ if a.Status != subagents.StatusRunning && a.Status != subagents.StatusPending {
572 return false, "", killDisabledHint(a.Status)572 return false, "", killDisabledHint(a.Status)
573 }573 }
574 if err := d.stop(a.ID); err != nil {574 if err := d.stop(a.ID); err != nil {
@@ -603,14 +603,14 @@ func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
603 // suggest, arrow keys for caret movement, etc.). Submits stay in603 // suggest, arrow keys for caret movement, etc.). Submits stay in
604 // the view and clear the editor for the next message instead of604 // the view and clear the editor for the next message instead of
605 // closing it — see handlePromptKey for the always-on behaviour.605 // 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) {
607 a := d.selected()607 a := d.selected()
608 608
609 // Lazy-open the editor on first key into the transcript view, so609 // Lazy-open the editor on first key into the transcript view, so
610 // re-entering an agent after switching away picks up cleanly.610 // re-entering an agent after switching away picks up cleanly.
611 // Skipped for non-running agents (their inbox isn't listening;611 // Skipped for non-running agents (their inbox isn't listening;
612 // sending would just bounce).612 // 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 {
614 d.openPromptEditor(tui.Theme{}, a.ID)614 d.openPromptEditor(tui.Theme{}, a.ID)
615 }615 }
616 616
@@ -634,7 +634,7 @@ func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg stri
634 // Everything else — typed runes, Enter, Backspace, arrow keys634 // Everything else — typed runes, Enter, Backspace, arrow keys
635 // for caret movement, @-file picker — is editor input. If the635 // for caret movement, @-file picker — is editor input. If the
636 // agent isn't running there's no live editor; surface a hint.636 // 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 {
638 if k.Kind == tui.KeyRune && a != nil {638 if k.Kind == tui.KeyRune && a != nil {
639 return false, "", promptDisabledHint(a.Status)639 return false, "", promptDisabledHint(a.Status)
640 }640 }
@@ -644,10 +644,10 @@ func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg stri
644 }644 }
645 645
646 // openSpawnEditor enters the inline new-agent prompt. It deliberately646 // openSpawnEditor enters the inline new-agent prompt. It deliberately
647-// uses tui.Editor (not a one-line string) so swarm-new supports the same647+// uses tui.Editor (not a one-line string) so subagent-new supports the same
648 // editing model as the normal input: multiline paste collapse, drag-drop648 // editing model as the normal input: multiline paste collapse, drag-drop
649 // file/dir chips, cursor movement, and the same accent-bar prompt style.649 // 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) {
651 d.spawning = true651 d.spawning = true
652 d.newTaskEd = tui.NewEditor(d.editorPrompt(th))652 d.newTaskEd = tui.NewEditor(d.editorPrompt(th))
653 d.fileSuggest = newFileSuggester()653 d.fileSuggest = newFileSuggester()
@@ -662,7 +662,7 @@ func (d *swarmDialog) openSpawnEditor(th tui.Theme) {
662 // submits "/model" from inside the spawn editor; on select we662 // submits "/model" from inside the spawn editor; on select we
663 // capture the new pendingModel/Provider and reopen the spawn663 // capture the new pendingModel/Provider and reopen the spawn
664 // editor with the previously-typed task buffer restored.664 // editor with the previously-typed task buffer restored.
665-func (d *swarmDialog) openModelPicker() {665+func (d *subagentsDialog) openModelPicker() {
666 d.spawning = false666 d.spawning = false
667 d.newTaskEd = nil667 d.newTaskEd = nil
668 d.fileSuggest = nil668 d.fileSuggest = nil
@@ -677,7 +677,7 @@ func (d *swarmDialog) openModelPicker() {
677 // pendingModel + pendingProvider; Esc leaves them unchanged (the677 // pendingModel + pendingProvider; Esc leaves them unchanged (the
678 // user gets to keep whatever was inherited before they invoked678 // user gets to keep whatever was inherited before they invoked
679 // /model).679 // /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) {
681 if d.modelPicker == nil {681 if d.modelPicker == nil {
682 d.openModelPicker()682 d.openModelPicker()
683 }683 }
@@ -738,7 +738,7 @@ func stripSlashModelLine(buf string) string {
738 // openPromptEditor enters the inline "send a follow-up prompt" editor738 // openPromptEditor enters the inline "send a follow-up prompt" editor
739 // for the agent with id targetID. Mirrors openSpawnEditor so the739 // for the agent with id targetID. Mirrors openSpawnEditor so the
740 // editing experience (paste, @-picker, drop chips) is identical.740 // 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) {
742 d.prompting = true742 d.prompting = true
743 d.promptEd = tui.NewEditor(d.editorPrompt(th))743 d.promptEd = tui.NewEditor(d.editorPrompt(th))
744 d.promptSuggest = newFileSuggester()744 d.promptSuggest = newFileSuggester()
@@ -751,7 +751,7 @@ func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) {
751 // with the captured target id instead of d.spawn. Esc cancels and751 // with the captured target id instead of d.spawn. Esc cancels and
752 // returns the user to whichever view (list or transcript) was active752 // returns the user to whichever view (list or transcript) was active
753 // when 'p' was pressed.753 // 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) {
755 if d.promptEd == nil {755 if d.promptEd == nil {
756 d.openPromptEditor(tui.Theme{}, d.promptTargetID)756 d.openPromptEditor(tui.Theme{}, d.promptTargetID)
757 }757 }
@@ -868,7 +868,7 @@ func (d *swarmDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg strin
868 868
869 // closePromptEditor resets all prompt-editor state. Called on Esc and869 // closePromptEditor resets all prompt-editor state. Called on Esc and
870 // after submit so a subsequent 'p' starts from a clean slate.870 // after submit so a subsequent 'p' starts from a clean slate.
871-func (d *swarmDialog) closePromptEditor() {871+func (d *subagentsDialog) closePromptEditor() {
872 d.prompting = false872 d.prompting = false
873 d.promptEd = nil873 d.promptEd = nil
874 d.promptSuggest = nil874 d.promptSuggest = nil
@@ -878,7 +878,7 @@ func (d *swarmDialog) closePromptEditor() {
878 // handleSpawnKey owns the keystrokes while the inline new-agent editor878 // handleSpawnKey owns the keystrokes while the inline new-agent editor
879 // is open. It mirrors the normal input's @ file-picker path and submit879 // is open. It mirrors the normal input's @ file-picker path and submit
880 // expansion, then asks the host to spawn.880 // 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) {
882 if d.newTaskEd == nil {882 if d.newTaskEd == nil {
883 d.openSpawnEditor(tui.Theme{})883 d.openSpawnEditor(tui.Theme{})
884 }884 }
@@ -986,7 +986,7 @@ func (d *swarmDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string
986 return false, "", ""986 return false, "", ""
987 }987 }
988 988
989-func (d *swarmDialog) selected() *swarm.AgentSnapshot {989+func (d *subagentsDialog) selected() *subagents.AgentSnapshot {
990 if d.cursor < 0 || d.cursor >= len(d.rows) {990 if d.cursor < 0 || d.cursor >= len(d.rows) {
991 return nil991 return nil
992 }992 }
@@ -996,7 +996,7 @@ func (d *swarmDialog) selected() *swarm.AgentSnapshot {
996 996
997 // Render returns the dialog lines. Re-reads the snapshot each call so997 // Render returns the dialog lines. Re-reads the snapshot each call so
998 // the dashboard updates as agents make progress.998 // 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 {
1000 if !d.Active() {1000 if !d.Active() {
1001 return nil1001 return nil
1002 }1002 }
@@ -1006,7 +1006,7 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string {
1006 return d.renderTranscript(th, width)1006 return d.renderTranscript(th, width)
1007 }1007 }
1008 1008
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)}
1010 if d.prompting {1010 if d.prompting {
1011 return d.renderPromptEditor(th, width, out)1011 return d.renderPromptEditor(th, width, out)
1012 }1012 }
@@ -1061,17 +1061,17 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string {
1061 return out1061 return out
1062 }1062 }
1063 if len(d.rows) == 0 {1063 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>)"))
1065 out = append(out, frameRule(th, width))1065 out = append(out, frameRule(th, width))
1066 return out1066 return out
1067 }1067 }
1068 1068
1069 // Column header for readability.1069 // 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")
1071 out = append(out, th.FG256(th.Muted, header))1071 out = append(out, th.FG256(th.Muted, header))
1072 1072
1073 for i, r := range d.rows {1073 for i, r := range d.rows {
1074- row := formatSwarmRow(r, width-2)1074+ row := formatSupervisorRow(r, width-2)
1075 if i == d.cursor {1075 if i == d.cursor {
1076 out = append(out, th.PadHighlight(" "+row, width))1076 out = append(out, th.PadHighlight(" "+row, width))
1077 } else {1077 } else {
@@ -1082,14 +1082,14 @@ func (d *swarmDialog) Render(th tui.Theme, width int) []string {
1082 return out1082 return out
1083 }1083 }
1084 1084
1085-func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {1085+func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string {
1086 a := d.selected()1086 a := d.selected()
1087 if a == nil {1087 if a == nil {
1088 d.viewing = false1088 d.viewing = false
1089 return d.Render(th, width)1089 return d.Render(th, width)
1090 }1090 }
1091 header := []string{1091 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),
1093 " " + th.FG256(th.Muted, "task: "+a.Task),1093 " " + th.FG256(th.Muted, "task: "+a.Task),
1094 " " + th.FG256(th.Muted, "dir: "+a.Dir),1094 " " + th.FG256(th.Muted, "dir: "+a.Dir),
1095 " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),1095 " " + 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 {
1141 // That gives the user the chat UX they expect (transcript grows1141 // That gives the user the chat UX they expect (transcript grows
1142 // with the window, arrow keys belong to the editor, no "↑ N1142 // with the window, arrow keys belong to the editor, no "↑ N
1143 // more above" cliff).1143 // more above" cliff).
1144- body := renderSwarmTranscriptBlocks(raw, th, width, d.compactMode)1144+ body := renderSupervisorTranscriptBlocks(raw, th, width, d.compactMode)
1145 1145
1146 out := header1146 out := header
1147 out = append(out, body...)1147 out = append(out, body...)
@@ -1161,11 +1161,11 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {
1161 // row, the editor itself, another breathing row, and a single muted1161 // row, the editor itself, another breathing row, and a single muted
1162 // hint. For non-running agents we render a short "can't send while1162 // hint. For non-running agents we render a short "can't send while
1163 // X" note instead so the user knows why typing is disabled.1163 // 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 {
1165 if a == nil {1165 if a == nil {
1166 return out1166 return out
1167 }1167 }
1168- if a.Status != swarm.StatusRunning || d.send == nil {1168+ if a.Status != subagents.StatusRunning || d.send == nil {
1169 out = append(out, "")1169 out = append(out, "")
1170 out = append(out, " "+th.FG256(th.Muted, promptDisabledHint(a.Status)))1170 out = append(out, " "+th.FG256(th.Muted, promptDisabledHint(a.Status)))
1171 return out1171 return out
@@ -1227,11 +1227,11 @@ func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width i
1227 // suppress the spinner so transient / unfamiliar activity strings1227 // suppress the spinner so transient / unfamiliar activity strings
1228 // still animate. Detached / done / failed / killed never animate —1228 // still animate. Detached / done / failed / killed never animate —
1229 // they aren't actively producing output.1229 // they aren't actively producing output.
1230-func agentIsBusy(a *swarm.AgentSnapshot) bool {1230+func agentIsBusy(a *subagents.AgentSnapshot) bool {
1231 if a == nil {1231 if a == nil {
1232 return false1232 return false
1233 }1233 }
1234- if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending {1234+ if a.Status != subagents.StatusRunning && a.Status != subagents.StatusPending {
1235 return false1235 return false
1236 }1236 }
1237 switch strings.TrimSpace(a.Activity) {1237 switch strings.TrimSpace(a.Activity) {
@@ -1241,7 +1241,7 @@ func agentIsBusy(a *swarm.AgentSnapshot) bool {
1241 return true1241 return true
1242 }1242 }
1243 1243
1244-// renderSwarmTranscriptBlocks converts the agent's flat transcript1244+// renderSupervisorTranscriptBlocks converts the agent's flat transcript
1245 // log into a slice of display rows styled like the main chat: user1245 // log into a slice of display rows styled like the main chat: user
1246 // echoes in bubble rows, assistant prose through tui.RenderMarkdown,1246 // echoes in bubble rows, assistant prose through tui.RenderMarkdown,
1247 // stderr / error chatter tinted. Each output row is fully indented1247 // stderr / error chatter tinted. Each output row is fully indented
@@ -1252,7 +1252,7 @@ func agentIsBusy(a *swarm.AgentSnapshot) bool {
1252 // whole block together — essential for markdown, which needs the1252 // whole block together — essential for markdown, which needs the
1253 // full message body in one piece to detect fenced code spans,1253 // full message body in one piece to detect fenced code spans,
1254 // bullet lists, and so on.1254 // 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 {
1256 type blockKind int1256 type blockKind int
1257 const (1257 const (
1258 kindAssistant blockKind = iota1258 kindAssistant blockKind = iota
@@ -1342,7 +1342,7 @@ func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compac
1342 // the pre-built header (frame header for list view, or the agent1342 // the pre-built header (frame header for list view, or the agent
1343 // metadata block for transcript view); we append onto it and close1343 // metadata block for transcript view); we append onto it and close
1344 // with the standard frame rule.1344 // 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 {
1346 if d.promptEd == nil {1346 if d.promptEd == nil {
1347 d.openPromptEditor(th, d.promptTargetID)1347 d.openPromptEditor(th, d.promptTargetID)
1348 }1348 }
@@ -1375,17 +1375,20 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string)
1375 return out1375 return out
1376 }1376 }
1377 1377
1378-// formatSwarmRow is the one-line summary shown per agent.1378+// formatSupervisorRow is the one-line summary shown per agent.
1379 //1379 //
1380 // Layout (fixed-width columns, then free-form activity):1380 // Layout (fixed-width columns, then free-form activity):
1381 //1381 //
1382 // STATUS ID AGE ACTIVITY1382 // STATUS ID AGE ACTIVITY
1383 // ● run fix-login-12345 3m editing main.go1383 // ● run fix-login-12345 3m editing main.go
1384 // ✓ done write-tests-67890 1h done1384 // ✓ done write-tests-67890 1h done
1385-func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {1385+func formatSupervisorRow(r subagents.AgentSnapshot, maxWidth int) string {
1386 status := statusLabel(r.Status)1386 status := statusLabel(r.Status)
1387+ if r.ProcessState != "" && r.TurnState != "" {
1388+ status = truncateLineSafe(status+" "+string(r.ProcessState)+"/"+string(r.TurnState), 18)
1389+ }
1387 age := formatAge(r.Started)1390 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)
1389 room := maxWidth - len([]rune(left))1392 room := maxWidth - len([]rune(left))
1390 if room < 10 {1393 if room < 10 {
1391 room = 101394 room = 10
@@ -1411,19 +1414,19 @@ func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {
1411 1414
1412 // statusLabel returns a short, padded status badge. Kept stable across1415 // statusLabel returns a short, padded status badge. Kept stable across
1413 // runs (no colours embedded) so tests can match it deterministically.1416 // runs (no colours embedded) so tests can match it deterministically.
1414-func statusLabel(s swarm.Status) string {1417+func statusLabel(s subagents.Status) string {
1415 switch s {1418 switch s {
1416- case swarm.StatusPending:1419+ case subagents.StatusPending:
1417 return "● pend"1420 return "● pend"
1418- case swarm.StatusRunning:1421+ case subagents.StatusRunning:
1419 return "● run"1422 return "● run"
1420- case swarm.StatusDone:1423+ case subagents.StatusDone:
1421 return "✓ done"1424 return "✓ done"
1422- case swarm.StatusFailed:1425+ case subagents.StatusFailed:
1423 return "✗ fail"1426 return "✗ fail"
1424- case swarm.StatusKilled:1427+ case subagents.StatusKilled:
1425 return "■ kill"1428 return "■ kill"
1426- case swarm.StatusDetached:1429+ case subagents.StatusDetached:
1427 return "○ detach"1430 return "○ detach"
1428 }1431 }
1429 return string(s)1432 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 (
9 "testing"9 "testing"
10 "time"10 "time"
11 11
12- "github.com/patriceckhart/zot/packages/agent/swarm"12+ "github.com/patriceckhart/zot/packages/agent/subagents"
13 "github.com/patriceckhart/zot/packages/provider"13 "github.com/patriceckhart/zot/packages/provider"
14 "github.com/patriceckhart/zot/packages/tui"14 "github.com/patriceckhart/zot/packages/tui"
15 )15 )
16 16
17 // staticSnapshots returns a deterministic snapshot slice for the17 // staticSnapshots returns a deterministic snapshot slice for the
18 // dialog to render.18 // dialog to render.
19-func staticSnapshots(rows ...swarm.AgentSnapshot) func() []swarm.AgentSnapshot {19+func staticSnapshots(rows ...subagents.AgentSnapshot) func() []subagents.AgentSnapshot {
20- return func() []swarm.AgentSnapshot {20+ return func() []subagents.AgentSnapshot {
21- out := make([]swarm.AgentSnapshot, len(rows))21+ out := make([]subagents.AgentSnapshot, len(rows))
22 copy(out, rows)22 copy(out, rows)
23 return out23 return out
24 }24 }
25 }25 }
26 26
27-func TestSwarmDialogEmptyState(t *testing.T) {27+func TestSubagentsDialogEmptyState(t *testing.T) {
28- d := newSwarmDialog()28+ d := newSubagentsDialog()
29 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")29 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
30 lines := d.Render(tui.Theme{}, 80)30 lines := d.Render(tui.Theme{}, 80)
31 joined := strings.Join(lines, "\n")31 joined := strings.Join(lines, "\n")
@@ -37,13 +37,13 @@ func TestSwarmDialogEmptyState(t *testing.T) {
37 }37 }
38 }38 }
39 39
40-func TestSwarmDialogRendersRows(t *testing.T) {40+func TestSubagentsDialogRendersRows(t *testing.T) {
41 now := time.Now()41 now := time.Now()
42- rows := []swarm.AgentSnapshot{42+ rows := []subagents.AgentSnapshot{
43- {ID: "alpha-1", Task: "fix login", Status: swarm.StatusRunning, Activity: "editing file", Started: now},43+ {ID: "alpha-1", Task: "fix login", Status: subagents.StatusRunning, Activity: "editing file", Started: now},
44- {ID: "beta-2", Task: "write tests", Status: swarm.StatusDone, Activity: "done", Started: now},44+ {ID: "beta-2", Task: "write tests", Status: subagents.StatusDone, Activity: "done", Started: now},
45 }45 }
46- d := newSwarmDialog()46+ d := newSubagentsDialog()
47 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")47 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
48 out := strings.Join(d.Render(tui.Theme{}, 100), "\n")48 out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
49 for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} {49 for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} {
@@ -53,14 +53,14 @@ func TestSwarmDialogRendersRows(t *testing.T) {
53 }53 }
54 }54 }
55 55
56-func TestSwarmDialogStopAndRemoveCallbacks(t *testing.T) {56+func TestSubagentsDialogStopAndRemoveCallbacks(t *testing.T) {
57 now := time.Now()57 now := time.Now()
58- rows := []swarm.AgentSnapshot{58+ rows := []subagents.AgentSnapshot{
59- {ID: "alpha-1", Status: swarm.StatusRunning, Started: now},59+ {ID: "alpha-1", Status: subagents.StatusRunning, Started: now},
60 }60 }
61 stops := 061 stops := 0
62 removes := 062 removes := 0
63- d := newSwarmDialog()63+ d := newSubagentsDialog()
64 d.Open(64 d.Open(
65 staticSnapshots(rows...),65 staticSnapshots(rows...),
66 func(id string) error { stops++; return nil },66 func(id string) error { stops++; return nil },
@@ -90,12 +90,12 @@ func TestSwarmDialogStopAndRemoveCallbacks(t *testing.T) {
90 }90 }
91 }91 }
92 92
93-func TestSwarmDialogEnterShowsTranscript(t *testing.T) {93+func TestSubagentsDialogEnterShowsTranscript(t *testing.T) {
94 now := time.Now()94 now := time.Now()
95- rows := []swarm.AgentSnapshot{95+ rows := []subagents.AgentSnapshot{
96- {ID: "alpha-1", Task: "do stuff", Status: swarm.StatusDone, Tail: "line a\nline b", Started: now},96+ {ID: "alpha-1", Task: "do stuff", Status: subagents.StatusDone, Tail: "line a\nline b", Started: now},
97 }97 }
98- d := newSwarmDialog()98+ d := newSubagentsDialog()
99 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")99 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
100 _ = d.Render(tui.Theme{}, 80)100 _ = d.Render(tui.Theme{}, 80)
101 d.HandleKey(tui.Key{Kind: tui.KeyEnter})101 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
@@ -113,8 +113,8 @@ func TestSwarmDialogEnterShowsTranscript(t *testing.T) {
113 }113 }
114 }114 }
115 115
116-func TestSwarmDialogEscClosesList(t *testing.T) {116+func TestSubagentsDialogEscClosesList(t *testing.T) {
117- d := newSwarmDialog()117+ d := newSubagentsDialog()
118 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")118 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
119 closed, _, _ := d.HandleKey(tui.Key{Kind: tui.KeyEsc})119 closed, _, _ := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
120 if !closed {120 if !closed {
@@ -125,12 +125,12 @@ func TestSwarmDialogEscClosesList(t *testing.T) {
125 }125 }
126 }126 }
127 127
128-// TestSwarmDialogSpawnFlow drives the inline n-prompt end-to-end:128+// TestSubagentsDialogSpawnFlow drives the inline n-prompt end-to-end:
129 // press n, type a task, press enter, observe the spawn callback129 // press n, type a task, press enter, observe the spawn callback
130 // fires with the right text and the prompt closes.130 // fires with the right text and the prompt closes.
131-func TestSwarmDialogSpawnFlow(t *testing.T) {131+func TestSubagentsDialogSpawnFlow(t *testing.T) {
132 var spawned string132 var spawned string
133- d := newSwarmDialog()133+ d := newSubagentsDialog()
134 d.Open(134 d.Open(
135 staticSnapshots(),135 staticSnapshots(),
136 nil, nil,136 nil, nil,
@@ -173,13 +173,13 @@ func TestSwarmDialogSpawnFlow(t *testing.T) {
173 }173 }
174 }174 }
175 175
176-func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) {176+func TestSubagentsDialogSpawnFilePickerFlow(t *testing.T) {
177 cwd := t.TempDir()177 cwd := t.TempDir()
178 if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil {178 if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil {
179 t.Fatal(err)179 t.Fatal(err)
180 }180 }
181 var spawned string181 var spawned string
182- d := newSwarmDialog()182+ d := newSubagentsDialog()
183 d.Open(staticSnapshots(), nil, nil, func(task, _, _ string) error { spawned = task; return nil }, nil, nil, cwd)183 d.Open(staticSnapshots(), nil, nil, func(task, _, _ string) error { spawned = task; return nil }, nil, nil, cwd)
184 _ = d.Render(tui.Theme{}, 80)184 _ = d.Render(tui.Theme{}, 80)
185 185
@@ -200,7 +200,7 @@ func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) {
200 }200 }
201 201
202 // A second Enter submits. The chip should expand to the absolute202 // 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.
204 _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter})204 _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
205 if errMsg != "" {205 if errMsg != "" {
206 t.Fatalf("unexpected err: %s", errMsg)206 t.Fatalf("unexpected err: %s", errMsg)
@@ -214,9 +214,9 @@ func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) {
214 }214 }
215 }215 }
216 216
217-func TestSwarmDialogSpawnEscCancels(t *testing.T) {217+func TestSubagentsDialogSpawnEscCancels(t *testing.T) {
218 called := false218 called := false
219- d := newSwarmDialog()219+ d := newSubagentsDialog()
220 d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { called = true; return nil }, nil, nil, "")220 d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { called = true; return nil }, nil, nil, "")
221 _ = d.Render(tui.Theme{}, 80)221 _ = d.Render(tui.Theme{}, 80)
222 222
@@ -236,8 +236,8 @@ func TestSwarmDialogSpawnEscCancels(t *testing.T) {
236 }236 }
237 }237 }
238 238
239-func TestSwarmDialogNoNWithoutSpawnCallback(t *testing.T) {239+func TestSubagentsDialogNoNWithoutSpawnCallback(t *testing.T) {
240- d := newSwarmDialog()240+ d := newSubagentsDialog()
241 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") // spawn callback nil241 d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") // spawn callback nil
242 _ = d.Render(tui.Theme{}, 80)242 _ = d.Render(tui.Theme{}, 80)
243 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})243 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
@@ -246,23 +246,23 @@ func TestSwarmDialogNoNWithoutSpawnCallback(t *testing.T) {
246 }246 }
247 }247 }
248 248
249-// TestSwarmDialogFullTranscriptRendersEverything pins the /btw-style249+// TestSubagentsDialogFullTranscriptRendersEverything pins the /btw-style
250 // rendering: the dialog NEVER clips the transcript into a fixed250 // rendering: the dialog NEVER clips the transcript into a fixed
251 // window with internal scroll. Every line goes into the rendered251 // window with internal scroll. Every line goes into the rendered
252 // output and the host's outer scrollback owns overflow. This is252 // output and the host's outer scrollback owns overflow. This is
253 // what makes the transcript grow with the window and with the253 // what makes the transcript grow with the window and with the
254 // content, instead of cutting off at row 16 with a "↑ N more above"254 // content, instead of cutting off at row 16 with a "↑ N more above"
255 // indicator.255 // indicator.
256-func TestSwarmDialogFullTranscriptRendersEverything(t *testing.T) {256+func TestSubagentsDialogFullTranscriptRendersEverything(t *testing.T) {
257 lines := make([]string, 60)257 lines := make([]string, 60)
258 for i := range lines {258 for i := range lines {
259 lines[i] = fmt.Sprintf("line %d", i)259 lines[i] = fmt.Sprintf("line %d", i)
260 }260 }
261- rows := []swarm.AgentSnapshot{{261+ rows := []subagents.AgentSnapshot{{
262- ID: "alpha-1", Task: "x", Status: swarm.StatusDone,262+ ID: "alpha-1", Task: "x", Status: subagents.StatusDone,
263 Lines: lines, Started: time.Now(),263 Lines: lines, Started: time.Now(),
264 }}264 }}
265- d := newSwarmDialog()265+ d := newSubagentsDialog()
266 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")266 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
267 _ = d.Render(tui.Theme{}, 80)267 _ = d.Render(tui.Theme{}, 80)
268 d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open viewer268 d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open viewer
@@ -289,16 +289,16 @@ func TestSwarmDialogFullTranscriptRendersEverything(t *testing.T) {
289 }289 }
290 }290 }
291 291
292-// TestSwarmDialogOpenViewingJumpsToAgent regression-tests /swarm logs292+// TestSubagentsDialogOpenViewingJumpsToAgent regression-tests /subagents logs
293 // <id>: OpenViewing should select the row whose id matches the293 // <id>: OpenViewing should select the row whose id matches the
294 // (possibly truncated) argument and enter the transcript view294 // (possibly truncated) argument and enter the transcript view
295 // directly without showing the list first.295 // directly without showing the list first.
296-func TestSwarmDialogOpenViewingJumpsToAgent(t *testing.T) {296+func TestSubagentsDialogOpenViewingJumpsToAgent(t *testing.T) {
297- rows := []swarm.AgentSnapshot{297+ rows := []subagents.AgentSnapshot{
298 {ID: "alpha-1", Lines: []string{"hi from alpha"}, Started: time.Now()},298 {ID: "alpha-1", Lines: []string{"hi from alpha"}, Started: time.Now()},
299 {ID: "beta-2", Lines: []string{"hi from beta"}, Started: time.Now()},299 {ID: "beta-2", Lines: []string{"hi from beta"}, Started: time.Now()},
300 }300 }
301- d := newSwarmDialog()301+ d := newSubagentsDialog()
302 ok := d.OpenViewing("beta", staticSnapshots(rows...), nil, nil, nil, nil, nil, "")302 ok := d.OpenViewing("beta", staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
303 if !ok {303 if !ok {
304 t.Fatal("prefix match did not resolve")304 t.Fatal("prefix match did not resolve")
@@ -312,24 +312,24 @@ func TestSwarmDialogOpenViewingJumpsToAgent(t *testing.T) {
312 }312 }
313 }313 }
314 314
315-func TestSwarmDialogOpenViewingUnknownIDReturnsFalse(t *testing.T) {315+func TestSubagentsDialogOpenViewingUnknownIDReturnsFalse(t *testing.T) {
316- d := newSwarmDialog()316+ d := newSubagentsDialog()
317 ok := d.OpenViewing("nope", staticSnapshots(), nil, nil, nil, nil, nil, "")317 ok := d.OpenViewing("nope", staticSnapshots(), nil, nil, nil, nil, nil, "")
318 if ok {318 if ok {
319 t.Fatal("expected miss for unknown id")319 t.Fatal("expected miss for unknown id")
320 }320 }
321 }321 }
322 322
323-// TestSwarmDialogPromptFlowFromList drives the inline p-prompt:323+// TestSubagentsDialogPromptFlowFromList drives the inline p-prompt:
324 // press p on the selected agent, type a message, press enter,324 // press p on the selected agent, type a message, press enter,
325 // and assert the send callback fired with the right id+text.325 // and assert the send callback fired with the right id+text.
326-func TestSwarmDialogPromptFlowFromList(t *testing.T) {326+func TestSubagentsDialogPromptFlowFromList(t *testing.T) {
327- rows := []swarm.AgentSnapshot{327+ rows := []subagents.AgentSnapshot{
328- {ID: "alpha-1", Status: swarm.StatusRunning, Started: time.Now()},328+ {ID: "alpha-1", Status: subagents.StatusRunning, Started: time.Now()},
329 }329 }
330 var gotID, gotText string330 var gotID, gotText string
331 sends := 0331 sends := 0
332- d := newSwarmDialog()332+ d := newSubagentsDialog()
333 d.Open(333 d.Open(
334 staticSnapshots(rows...),334 staticSnapshots(rows...),
335 nil, nil, nil,335 nil, nil, nil,
@@ -371,18 +371,18 @@ func TestSwarmDialogPromptFlowFromList(t *testing.T) {
371 }371 }
372 }372 }
373 373
374-// TestSwarmDialogPromptFromTranscriptView pins the always-on editor374+// TestSubagentsDialogPromptFromTranscriptView pins the always-on editor
375 // behaviour for the transcript view: entering an agent immediately375 // behaviour for the transcript view: entering an agent immediately
376 // auto-opens the inline composer (no `p` needed), typing flows376 // auto-opens the inline composer (no `p` needed), typing flows
377 // straight into it, Enter sends, and the metadata stays visible.377 // straight into it, Enter sends, and the metadata stays visible.
378 // The transcript view is /btw-style now — you never leave the378 // The transcript view is /btw-style now — you never leave the
379 // view to send a follow-up.379 // view to send a follow-up.
380-func TestSwarmDialogPromptFromTranscriptView(t *testing.T) {380+func TestSubagentsDialogPromptFromTranscriptView(t *testing.T) {
381- rows := []swarm.AgentSnapshot{381+ rows := []subagents.AgentSnapshot{
382- {ID: "beta-2", Task: "do thing", Status: swarm.StatusRunning, Started: time.Now(), Tail: "hi from beta"},382+ {ID: "beta-2", Task: "do thing", Status: subagents.StatusRunning, Started: time.Now(), Tail: "hi from beta"},
383 }383 }
384 var gotID, gotText string384 var gotID, gotText string
385- d := newSwarmDialog()385+ d := newSubagentsDialog()
386 d.Open(386 d.Open(
387 staticSnapshots(rows...),387 staticSnapshots(rows...),
388 nil, nil, nil,388 nil, nil, nil,
@@ -404,7 +404,7 @@ func TestSwarmDialogPromptFromTranscriptView(t *testing.T) {
404 if !strings.Contains(out, "task: do thing") {404 if !strings.Contains(out, "task: do thing") {
405 t.Fatalf("transcript metadata not visible:\n%s", out)405 t.Fatalf("transcript metadata not visible:\n%s", out)
406 }406 }
407- if !strings.Contains(out, "swarm: beta-2") {407+ if !strings.Contains(out, "subagent: beta-2") {
408 t.Fatalf("frame header missing agent id:\n%s", out)408 t.Fatalf("frame header missing agent id:\n%s", out)
409 }409 }
410 410
@@ -430,12 +430,12 @@ func TestSwarmDialogPromptFromTranscriptView(t *testing.T) {
430 }430 }
431 }431 }
432 432
433-// TestSwarmDialogPromptEscCancels makes sure Esc inside the prompt433+// TestSubagentsDialogPromptEscCancels makes sure Esc inside the prompt
434 // editor aborts without invoking the send callback.434 // editor aborts without invoking the send callback.
435-func TestSwarmDialogPromptEscCancels(t *testing.T) {435+func TestSubagentsDialogPromptEscCancels(t *testing.T) {
436- rows := []swarm.AgentSnapshot{{ID: "gamma-3", Status: swarm.StatusRunning, Started: time.Now()}}436+ rows := []subagents.AgentSnapshot{{ID: "gamma-3", Status: subagents.StatusRunning, Started: time.Now()}}
437 sends := 0437 sends := 0
438- d := newSwarmDialog()438+ d := newSubagentsDialog()
439 d.Open(439 d.Open(
440 staticSnapshots(rows...),440 staticSnapshots(rows...),
441 nil, nil, nil,441 nil, nil, nil,
@@ -457,12 +457,12 @@ func TestSwarmDialogPromptEscCancels(t *testing.T) {
457 }457 }
458 }458 }
459 459
460-// TestSwarmDialogPWithoutSendCallback verifies that pressing 'p'460+// TestSubagentsDialogPWithoutSendCallback verifies that pressing 'p'
461 // when no send callback is wired is a silent no-op (mirrors the461 // when no send callback is wired is a silent no-op (mirrors the
462 // existing 'n' / no-spawn-callback contract).462 // existing 'n' / no-spawn-callback contract).
463-func TestSwarmDialogPWithoutSendCallback(t *testing.T) {463+func TestSubagentsDialogPWithoutSendCallback(t *testing.T) {
464- rows := []swarm.AgentSnapshot{{ID: "delta-4", Status: swarm.StatusRunning, Started: time.Now()}}464+ rows := []subagents.AgentSnapshot{{ID: "delta-4", Status: subagents.StatusRunning, Started: time.Now()}}
465- d := newSwarmDialog()465+ d := newSubagentsDialog()
466 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")466 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
467 _ = d.Render(tui.Theme{}, 80)467 _ = d.Render(tui.Theme{}, 80)
468 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})468 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
@@ -476,13 +476,13 @@ func TestSwarmDialogPWithoutSendCallback(t *testing.T) {
476 // dialog opens with the cursor on the first detached row (skipping476 // dialog opens with the cursor on the first detached row (skipping
477 // any leading running ones) so the user can press R immediately.477 // any leading running ones) so the user can press R immediately.
478 func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) {478 func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) {
479- rows := []swarm.AgentSnapshot{479+ rows := []subagents.AgentSnapshot{
480- {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()},480+ {ID: "live-1", Status: subagents.StatusRunning, Started: time.Now()},
481- {ID: "live-2", Status: swarm.StatusRunning, Started: time.Now()},481+ {ID: "live-2", Status: subagents.StatusRunning, Started: time.Now()},
482- {ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()},482+ {ID: "detached-1", Status: subagents.StatusDetached, Started: time.Now()},
483- {ID: "detached-2", Status: swarm.StatusKilled, Started: time.Now()},483+ {ID: "detached-2", Status: subagents.StatusKilled, Started: time.Now()},
484 }484 }
485- d := newSwarmDialog()485+ d := newSubagentsDialog()
486 count := d.OpenForResume(486 count := d.OpenForResume(
487 staticSnapshots(rows...),487 staticSnapshots(rows...),
488 nil, nil, nil, nil,488 nil, nil, nil, nil,
@@ -506,10 +506,10 @@ func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) {
506 // slash handler is responsible for surfacing the "no resumable506 // slash handler is responsible for surfacing the "no resumable
507 // agents" status string — the dialog itself just opens.507 // agents" status string — the dialog itself just opens.
508 func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) {508 func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) {
509- rows := []swarm.AgentSnapshot{509+ rows := []subagents.AgentSnapshot{
510- {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()},510+ {ID: "live-1", Status: subagents.StatusRunning, Started: time.Now()},
511 }511 }
512- d := newSwarmDialog()512+ d := newSubagentsDialog()
513 count := d.OpenForResume(513 count := d.OpenForResume(
514 staticSnapshots(rows...),514 staticSnapshots(rows...),
515 nil, nil, nil, nil,515 nil, nil, nil, nil,
@@ -528,15 +528,15 @@ func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) {
528 }528 }
529 529
530 // TestOpenForResumeREnterTriggersResume drives the full picker UX:530 // TestOpenForResumeREnterTriggersResume drives the full picker UX:
531-// /swarm resume → dialog opens parked on a resumable row → user531+// /subagents resume → dialog opens parked on a resumable row → user
532 // presses R → the resume callback fires for that exact id.532 // presses R → the resume callback fires for that exact id.
533 func TestOpenForResumeREnterTriggersResume(t *testing.T) {533 func TestOpenForResumeREnterTriggersResume(t *testing.T) {
534- rows := []swarm.AgentSnapshot{534+ rows := []subagents.AgentSnapshot{
535- {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()},535+ {ID: "live-1", Status: subagents.StatusRunning, Started: time.Now()},
536- {ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()},536+ {ID: "detached-1", Status: subagents.StatusDetached, Started: time.Now()},
537 }537 }
538 var resumed string538 var resumed string
539- d := newSwarmDialog()539+ d := newSubagentsDialog()
540 d.OpenForResume(540 d.OpenForResume(
541 staticSnapshots(rows...),541 staticSnapshots(rows...),
542 nil, nil, nil, nil,542 nil, nil, nil, nil,
@@ -561,7 +561,7 @@ func TestOpenForResumeREnterTriggersResume(t *testing.T) {
561 // the way of the inline editors (which need the cursor blink).561 // the way of the inline editors (which need the cursor blink).
562 func TestNeedsTickRefresh(t *testing.T) {562 func TestNeedsTickRefresh(t *testing.T) {
563 // Closed.563 // Closed.
564- d := newSwarmDialog()564+ d := newSubagentsDialog()
565 if d.NeedsTickRefresh() {565 if d.NeedsTickRefresh() {
566 t.Fatal("closed dialog should not request tick refresh")566 t.Fatal("closed dialog should not request tick refresh")
567 }567 }
@@ -573,7 +573,7 @@ func TestNeedsTickRefresh(t *testing.T) {
573 }573 }
574 574
575 // Transcript view: still background-driven; tick should refresh.575 // 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()}}
577 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")577 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
578 _ = d.Render(tui.Theme{}, 80)578 _ = d.Render(tui.Theme{}, 80)
579 d.HandleKey(tui.Key{Kind: tui.KeyEnter})579 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
@@ -585,7 +585,7 @@ func TestNeedsTickRefresh(t *testing.T) {
585 }585 }
586 586
587 // Spawn editor open: tick should NOT refresh (cursor blink).587 // Spawn editor open: tick should NOT refresh (cursor blink).
588- d = newSwarmDialog()588+ d = newSubagentsDialog()
589 d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "")589 d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "")
590 _ = d.Render(tui.Theme{}, 80)590 _ = d.Render(tui.Theme{}, 80)
591 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})591 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
@@ -597,8 +597,8 @@ func TestNeedsTickRefresh(t *testing.T) {
597 }597 }
598 598
599 // Prompt editor open: same suppression.599 // Prompt editor open: same suppression.
600- d = newSwarmDialog()600+ d = newSubagentsDialog()
601- rows = []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}}601+ rows = []subagents.AgentSnapshot{{ID: "a", Status: subagents.StatusRunning, Started: time.Now()}}
602 d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "")602 d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "")
603 _ = d.Render(tui.Theme{}, 80)603 _ = d.Render(tui.Theme{}, 80)
604 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})604 d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
@@ -616,12 +616,12 @@ func TestNeedsTickRefresh(t *testing.T) {
616 // stayed at the (now-hidden) main editor row when the user616 // stayed at the (now-hidden) main editor row when the user
617 // pressed 'p', so the visible caret looked wrong.617 // pressed 'p', so the visible caret looked wrong.
618 func TestCursorPosPromptEditor(t *testing.T) {618 func TestCursorPosPromptEditor(t *testing.T) {
619- rows := []swarm.AgentSnapshot{619+ rows := []subagents.AgentSnapshot{
620- {ID: "a", Task: "t", Status: swarm.StatusRunning, Started: time.Now()},620+ {ID: "a", Task: "t", Status: subagents.StatusRunning, Started: time.Now()},
621 }621 }
622 622
623 // From the list view.623 // From the list view.
624- d := newSwarmDialog()624+ d := newSubagentsDialog()
625 d.Open(staticSnapshots(rows...), nil, nil, nil,625 d.Open(staticSnapshots(rows...), nil, nil, nil,
626 func(string, string) error { return nil }, nil, "")626 func(string, string) error { return nil }, nil, "")
627 _ = d.Render(tui.Theme{}, 80)627 _ = d.Render(tui.Theme{}, 80)
@@ -635,7 +635,7 @@ func TestCursorPosPromptEditor(t *testing.T) {
635 }635 }
636 636
637 // From the transcript view (deeper offset due to metadata block).637 // From the transcript view (deeper offset due to metadata block).
638- d = newSwarmDialog()638+ d = newSubagentsDialog()
639 d.Open(staticSnapshots(rows...), nil, nil, nil,639 d.Open(staticSnapshots(rows...), nil, nil, nil,
640 func(string, string) error { return nil }, nil, "")640 func(string, string) error { return nil }, nil, "")
641 _ = d.Render(tui.Theme{}, 80)641 _ = d.Render(tui.Theme{}, 80)
@@ -651,7 +651,7 @@ func TestCursorPosPromptEditor(t *testing.T) {
651 }651 }
652 652
653 func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T) {653 func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T) {
654- d := newSwarmDialog()654+ d := newSubagentsDialog()
655 d.SetCurrentModel("claude-sonnet-4-5", "anthropic")655 d.SetCurrentModel("claude-sonnet-4-5", "anthropic")
656 d.Open(staticSnapshots(), nil, nil, func(task, model, provider string) error { return nil }, nil, nil, "")656 d.Open(staticSnapshots(), nil, nil, func(task, model, provider string) error { return nil }, nil, nil, "")
657 _ = d.Render(tui.Theme{}, 80)657 _ = d.Render(tui.Theme{}, 80)
@@ -670,19 +670,19 @@ func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T)
670 }670 }
671 }671 }
672 672
673-// TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression673+// TestSubagentsDialogPOnNonRunningAgentShowsHint covers the regression
674 // caught by the user's screenshot: pressing 'p' on a detached agent674 // caught by the user's screenshot: pressing 'p' on a detached agent
675 // dialled a dead unix socket and printed the raw path as an error.675 // dialled a dead unix socket and printed the raw path as an error.
676 // We now block the prompt editor for any non-running status and676 // We now block the prompt editor for any non-running status and
677 // surface a clear status-bar hint instead.677 // surface a clear status-bar hint instead.
678-func TestSwarmDialogPOnNonRunningAgentShowsHint(t *testing.T) {678+func TestSubagentsDialogPOnNonRunningAgentShowsHint(t *testing.T) {
679- for _, st := range []swarm.Status{679+ for _, st := range []subagents.Status{
680- swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,680+ subagents.StatusDetached, subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled,
681 } {681 } {
682 t.Run(string(st), func(t *testing.T) {682 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()}}
684 sends := 0684 sends := 0
685- d := newSwarmDialog()685+ d := newSubagentsDialog()
686 d.Open(staticSnapshots(rows...), nil, nil, nil,686 d.Open(staticSnapshots(rows...), nil, nil, nil,
687 func(string, string) error { sends++; return nil }, nil, "")687 func(string, string) error { sends++; return nil }, nil, "")
688 _ = d.Render(tui.Theme{}, 80)688 _ = d.Render(tui.Theme{}, 80)
@@ -700,19 +700,19 @@ func TestSwarmDialogPOnNonRunningAgentShowsHint(t *testing.T) {
700 if !strings.Contains(errMsg, string(st)) {700 if !strings.Contains(errMsg, string(st)) {
701 t.Errorf("hint = %q; should mention status %q", errMsg, st)701 t.Errorf("hint = %q; should mention status %q", errMsg, st)
702 }702 }
703- if !strings.Contains(errMsg, "resume") && st != swarm.StatusPending && st != swarm.StatusRunning {703+ if !strings.Contains(errMsg, "resume") && st != subagents.StatusPending && st != subagents.StatusRunning {
704 t.Errorf("hint = %q; should mention resume for resumable state", errMsg)704 t.Errorf("hint = %q; should mention resume for resumable state", errMsg)
705 }705 }
706 })706 })
707 }707 }
708 }708 }
709 709
710-// TestSwarmDialogPOnRunningAgentOpensEditor is the positive710+// TestSubagentsDialogPOnRunningAgentOpensEditor is the positive
711 // complement of the previous test: running agents still open the711 // complement of the previous test: running agents still open the
712 // editor normally.712 // editor normally.
713-func TestSwarmDialogPOnRunningAgentOpensEditor(t *testing.T) {713+func TestSubagentsDialogPOnRunningAgentOpensEditor(t *testing.T) {
714- rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}}714+ rows := []subagents.AgentSnapshot{{ID: "a", Status: subagents.StatusRunning, Started: time.Now()}}
715- d := newSwarmDialog()715+ d := newSubagentsDialog()
716 d.Open(staticSnapshots(rows...), nil, nil, nil,716 d.Open(staticSnapshots(rows...), nil, nil, nil,
717 func(string, string) error { return nil }, nil, "")717 func(string, string) error { return nil }, nil, "")
718 _ = d.Render(tui.Theme{}, 80)718 _ = d.Render(tui.Theme{}, 80)
@@ -726,11 +726,11 @@ func TestSwarmDialogPOnRunningAgentOpensEditor(t *testing.T) {
726 // user-friendly "isn't accepting input" hint, and unknown errors726 // user-friendly "isn't accepting input" hint, and unknown errors
727 // fall through verbatim.727 // fall through verbatim.
728 func TestFriendlySendErrRewritesNotReady(t *testing.T) {728 func TestFriendlySendErrRewritesNotReady(t *testing.T) {
729- hint := friendlySendErr("alpha-1", swarm.ErrNotReady)729+ hint := friendlySendErr("alpha-1", subagents.ErrNotReady)
730 if !strings.Contains(hint, "alpha-1") || !strings.Contains(hint, "resume") {730 if !strings.Contains(hint, "alpha-1") || !strings.Contains(hint, "resume") {
731 t.Errorf("friendlySendErr(ErrNotReady) = %q", hint)731 t.Errorf("friendlySendErr(ErrNotReady) = %q", hint)
732 }732 }
733- if strings.Contains(hint, "swarm:") {733+ if strings.Contains(hint, "subagent:") {
734 t.Errorf("friendlySendErr leaked raw error text: %q", hint)734 t.Errorf("friendlySendErr leaked raw error text: %q", hint)
735 }735 }
736 736
@@ -741,11 +741,11 @@ func TestFriendlySendErrRewritesNotReady(t *testing.T) {
741 }741 }
742 }742 }
743 743
744-// TestRenderSwarmTranscriptBlocks_GroupsByRole asserts the flat744+// TestRenderSupervisorTranscriptBlocks_GroupsByRole asserts the flat
745 // transcript log is regrouped into user / assistant / stderr /745 // transcript log is regrouped into user / assistant / stderr /
746 // error blocks before rendering. Each output row should be styled746 // error blocks before rendering. Each output row should be styled
747 // for its role rather than printed verbatim.747 // for its role rather than printed verbatim.
748-func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) {748+func TestRenderSupervisorTranscriptBlocks_GroupsByRole(t *testing.T) {
749 lines := []string{749 lines := []string{
750 "user: hello",750 "user: hello",
751 "hi there",751 "hi there",
@@ -753,7 +753,7 @@ func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) {
753 "stderr: warning thing",753 "stderr: warning thing",
754 "error: boom",754 "error: boom",
755 }755 }
756- out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false)756+ out := renderSupervisorTranscriptBlocks(lines, tui.Theme{}, 80, false)
757 joined := strings.Join(out, "\n")757 joined := strings.Join(out, "\n")
758 758
759 // The user echo should appear as a bubble row, which contains759 // The user echo should appear as a bubble row, which contains
@@ -780,17 +780,17 @@ func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) {
780 }780 }
781 }781 }
782 782
783-// TestRenderSwarmTranscriptBlocks_MarkdownStyling proves the783+// TestRenderSupervisorTranscriptBlocks_MarkdownStyling proves the
784 // assistant text goes through tui.RenderMarkdown: a literal **bold**784 // assistant text goes through tui.RenderMarkdown: a literal **bold**
785 // pair should become an ANSI bold sequence and bullet "-" should785 // pair should become an ANSI bold sequence and bullet "-" should
786 // become "•".786 // become "•".
787-func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) {787+func TestRenderSupervisorTranscriptBlocks_MarkdownStyling(t *testing.T) {
788 lines := []string{788 lines := []string{
789 "Here is a list:",789 "Here is a list:",
790 "- **bold** item",790 "- **bold** item",
791 "- plain item",791 "- plain item",
792 }792 }
793- out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false)793+ out := renderSupervisorTranscriptBlocks(lines, tui.Theme{}, 80, false)
794 joined := strings.Join(out, "\n")794 joined := strings.Join(out, "\n")
795 795
796 // ANSI bold on/off. Markdown rendering uses CSI 1 / 22.796 // ANSI bold on/off. Markdown rendering uses CSI 1 / 22.
@@ -807,11 +807,11 @@ func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) {
807 }807 }
808 }808 }
809 809
810-// TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines810+// TestRenderSupervisorTranscriptBlocks_GroupsAdjacentAssistantLines
811 // guards the contract that consecutive assistant lines form one811 // guards the contract that consecutive assistant lines form one
812 // markdown block. If we render each line in isolation, fenced code812 // markdown block. If we render each line in isolation, fenced code
813 // (and other multi-line constructs) would never close.813 // (and other multi-line constructs) would never close.
814-func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) {814+func TestRenderSupervisorTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) {
815 lines := []string{815 lines := []string{
816 "Some prose.",816 "Some prose.",
817 "```go",817 "```go",
@@ -821,7 +821,7 @@ func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T)
821 "```",821 "```",
822 "Done.",822 "Done.",
823 }823 }
824- out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80, false)824+ out := renderSupervisorTranscriptBlocks(lines, tui.Theme{}, 80, false)
825 joined := strings.Join(out, "\n")825 joined := strings.Join(out, "\n")
826 826
827 // The literal fence markers should be consumed by the markdown827 // The literal fence markers should be consumed by the markdown
@@ -838,19 +838,19 @@ func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T)
838 }838 }
839 }839 }
840 840
841-// TestSwarmDialogKOnNonRunningAgentShowsHint mirrors the prompt-841+// TestSubagentsDialogKOnNonRunningAgentShowsHint mirrors the prompt-
842 // disabled test: pressing 'k' on an already-terminal or detached842 // disabled test: pressing 'k' on an already-terminal or detached
843-// agent now surfaces a hint instead of routing to Swarm.Stop, which843+// agent now surfaces a hint instead of routing to Supervisor.Stop, which
844 // is a no-op for those states (and used to segfault for detached844 // is a no-op for those states (and used to segfault for detached
845 // agents before the cancel-nil guard).845 // agents before the cancel-nil guard).
846-func TestSwarmDialogKOnNonRunningAgentShowsHint(t *testing.T) {846+func TestSubagentsDialogKOnNonRunningAgentShowsHint(t *testing.T) {
847- for _, st := range []swarm.Status{847+ for _, st := range []subagents.Status{
848- swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,848+ subagents.StatusDetached, subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled,
849 } {849 } {
850 t.Run(string(st), func(t *testing.T) {850 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()}}
852 stops := 0852 stops := 0
853- d := newSwarmDialog()853+ d := newSubagentsDialog()
854 d.Open(staticSnapshots(rows...),854 d.Open(staticSnapshots(rows...),
855 func(string) error { stops++; return nil },855 func(string) error { stops++; return nil },
856 nil, nil, nil, nil, "")856 nil, nil, nil, nil, "")
@@ -866,14 +866,14 @@ func TestSwarmDialogKOnNonRunningAgentShowsHint(t *testing.T) {
866 }866 }
867 }867 }
868 868
869-// TestSwarmDialogSpawnFlow_CurrentModelInherited proves the spawn869+// TestSubagentsDialogSpawnFlow_CurrentModelInherited proves the spawn
870 // callback receives whatever model the host pinned via870 // callback receives whatever model the host pinned via
871 // SetCurrentModel before opening the dialog. This is how /model is871 // SetCurrentModel before opening the dialog. This is how /model is
872-// surfaced to swarm agents: the user picks via /model first, then872+// surfaced to subagent agents: the user picks via /model first, then
873-// /swarm n, and the new agent inherits.873+// /subagents n, and the new agent inherits.
874-func TestSwarmDialogSpawnFlow_CurrentModelInherited(t *testing.T) {874+func TestSubagentsDialogSpawnFlow_CurrentModelInherited(t *testing.T) {
875 var gotTask, gotModel, gotProv string875 var gotTask, gotModel, gotProv string
876- d := newSwarmDialog()876+ d := newSubagentsDialog()
877 d.SetCurrentModel("claude-sonnet-4-5", "anthropic")877 d.SetCurrentModel("claude-sonnet-4-5", "anthropic")
878 d.Open(staticSnapshots(), nil, nil,878 d.Open(staticSnapshots(), nil, nil,
879 func(task, model, prov string) error {879 func(task, model, prov string) error {
@@ -909,14 +909,14 @@ func TestSwarmDialogSpawnFlow_CurrentModelInherited(t *testing.T) {
909 }909 }
910 }910 }
911 911
912-// TestSwarmDialogSpawnEditor_SlashModelOpensPicker proves the in-912+// TestSubagentsDialogSpawnEditor_SlashModelOpensPicker proves the in-
913 // editor /model command pops the same model dialog the global913 // editor /model command pops the same model dialog the global
914 // /model uses, that picking a model captures it as the pending914 // /model uses, that picking a model captures it as the pending
915 // override, and that the spawn editor reopens with any previously-915 // override, and that the spawn editor reopens with any previously-
916 // typed task text intact so the user doesn't lose work.916 // 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) {
918 var gotTask, gotModel, gotProv string918 var gotTask, gotModel, gotProv string
919- d := newSwarmDialog()919+ d := newSubagentsDialog()
920 d.SetCurrentModel("old-model", "old-prov")920 d.SetCurrentModel("old-model", "old-prov")
921 d.Open(staticSnapshots(), nil, nil,921 d.Open(staticSnapshots(), nil, nil,
922 func(task, model, prov string) error {922 func(task, model, prov string) error {
@@ -984,12 +984,12 @@ func TestSwarmDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) {
984 }984 }
985 }985 }
986 986
987-// TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft confirms987+// TestSubagentsDialogSpawnEditor_SlashModelEscRestoresDraft confirms
988 // pressing Esc inside the picker (no selection) reopens the spawn988 // pressing Esc inside the picker (no selection) reopens the spawn
989 // editor with the previously-typed text intact AND leaves the989 // editor with the previously-typed text intact AND leaves the
990 // pending model untouched, so the user can bail out cleanly.990 // pending model untouched, so the user can bail out cleanly.
991-func TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) {991+func TestSubagentsDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) {
992- d := newSwarmDialog()992+ d := newSubagentsDialog()
993 d.SetCurrentModel("keep-me", "keep-prov")993 d.SetCurrentModel("keep-me", "keep-prov")
994 d.Open(staticSnapshots(), nil, nil,994 d.Open(staticSnapshots(), nil, nil,
995 func(string, string, string) error { return nil },995 func(string, string, string) error { return nil },
@@ -1041,13 +1041,13 @@ func TestStripSlashModelLine(t *testing.T) {
1041 }1041 }
1042 }1042 }
1043 1043
1044-// TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault confirms that1044+// TestSubagentsDialogSpawnFlow_NoModelInheritsChildDefault confirms that
1045 // when the host doesn't pin a model (SetCurrentModel never called or1045 // when the host doesn't pin a model (SetCurrentModel never called or
1046 // called with empty strings), the spawn callback receives empty1046 // called with empty strings), the spawn callback receives empty
1047 // model/provider and the child subprocess resolves its own default.1047 // model/provider and the child subprocess resolves its own default.
1048-func TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) {1048+func TestSubagentsDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) {
1049 var gotModel, gotProv string1049 var gotModel, gotProv string
1050- d := newSwarmDialog()1050+ d := newSubagentsDialog()
1051 d.Open(staticSnapshots(), nil, nil,1051 d.Open(staticSnapshots(), nil, nil,
1052 func(_, model, prov string) error {1052 func(_, model, prov string) error {
1053 gotModel, gotProv = model, prov1053 gotModel, gotProv = model, prov
@@ -1066,20 +1066,20 @@ func TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) {
1066 }1066 }
1067 }1067 }
1068 1068
1069-// TestSwarmTranscriptGrowsWithNewMessages confirms the /btw-style1069+// TestSupervisorTranscriptGrowsWithNewMessages confirms the /btw-style
1070 // "the transcript grows with the content" property: when the agent1070 // "the transcript grows with the content" property: when the agent
1071 // reply lands in the snapshot, the next render includes it in full,1071 // reply lands in the snapshot, the next render includes it in full,
1072 // alongside everything that was already there. Replaces the old1072 // alongside everything that was already there. Replaces the old
1073 // followTail/scroll tests that pinned a viewport mechanic we no1073 // followTail/scroll tests that pinned a viewport mechanic we no
1074 // longer have.1074 // longer have.
1075-func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) {1075+func TestSupervisorTranscriptGrowsWithNewMessages(t *testing.T) {
1076- cur := []swarm.AgentSnapshot{{1076+ cur := []subagents.AgentSnapshot{{
1077- ID: "agent-1", Task: "x", Status: swarm.StatusRunning,1077+ ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
1078 Lines: []string{"old line 1", "old line 2"}, Started: time.Now(),1078 Lines: []string{"old line 1", "old line 2"}, Started: time.Now(),
1079 }}1079 }}
1080- snap := func() []swarm.AgentSnapshot { return cur }1080+ snap := func() []subagents.AgentSnapshot { return cur }
1081 1081
1082- d := newSwarmDialog()1082+ d := newSubagentsDialog()
1083 d.Open(snap, nil, nil, nil,1083 d.Open(snap, nil, nil, nil,
1084 func(string, string) error { return nil },1084 func(string, string) error { return nil },
1085 nil, "")1085 nil, "")
@@ -1093,8 +1093,8 @@ func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) {
1093 1093
1094 // Agent appends a fresh reply: the very next render must show1094 // Agent appends a fresh reply: the very next render must show
1095 // both the new content AND keep the old content (no clipping).1095 // both the new content AND keep the old content (no clipping).
1096- cur = []swarm.AgentSnapshot{{1096+ cur = []subagents.AgentSnapshot{{
1097- ID: "agent-1", Task: "x", Status: swarm.StatusRunning,1097+ ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
1098 Lines: []string{"old line 1", "old line 2", "new reply A", "new reply B"},1098 Lines: []string{"old line 1", "old line 2", "new reply A", "new reply B"},
1099 Started: cur[0].Started,1099 Started: cur[0].Started,
1100 }}1100 }}
@@ -1106,19 +1106,19 @@ func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) {
1106 }1106 }
1107 }1107 }
1108 1108
1109-// TestSwarmTranscriptBtwStyleMultipleSends pins the /btw-style flow1109+// TestSupervisorTranscriptBtwStyleMultipleSends pins the /btw-style flow
1110 // for the transcript view: after one Enter-submit the editor stays1110 // for the transcript view: after one Enter-submit the editor stays
1111 // mounted and clears, and a second message can be composed and sent1111 // mounted and clears, and a second message can be composed and sent
1112 // without any modal toggle. Regresses the previous design where1112 // without any modal toggle. Regresses the previous design where
1113 // every send closed the prompt and forced the user to press `p`1113 // every send closed the prompt and forced the user to press `p`
1114 // again.1114 // again.
1115-func TestSwarmTranscriptBtwStyleMultipleSends(t *testing.T) {1115+func TestSupervisorTranscriptBtwStyleMultipleSends(t *testing.T) {
1116- rows := []swarm.AgentSnapshot{{1116+ rows := []subagents.AgentSnapshot{{
1117- ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(),1117+ ID: "agent-1", Task: "x", Status: subagents.StatusRunning, Started: time.Now(),
1118 Lines: []string{"warm-up"},1118 Lines: []string{"warm-up"},
1119 }}1119 }}
1120 var sent []string1120 var sent []string
1121- d := newSwarmDialog()1121+ d := newSubagentsDialog()
1122 d.Open(staticSnapshots(rows...), nil, nil, nil,1122 d.Open(staticSnapshots(rows...), nil, nil, nil,
1123 func(_, text string) error { sent = append(sent, text); return nil },1123 func(_, text string) error { sent = append(sent, text); return nil },
1124 nil, "")1124 nil, "")
@@ -1145,18 +1145,18 @@ func TestSwarmTranscriptBtwStyleMultipleSends(t *testing.T) {
1145 }1145 }
1146 }1146 }
1147 1147
1148-// TestSwarmTranscriptEscClosesView pins the Esc semantics for the1148+// TestSupervisorTranscriptEscClosesView pins the Esc semantics for the
1149 // /btw-style transcript view: a single Esc closes the whole view1149 // /btw-style transcript view: a single Esc closes the whole view
1150 // (back to the dashboard list), regardless of whether the editor1150 // (back to the dashboard list), regardless of whether the editor
1151 // is currently composing. Without this routing the editor's own1151 // is currently composing. Without this routing the editor's own
1152 // Esc handler would just clear the modal and leave the user stuck1152 // Esc handler would just clear the modal and leave the user stuck
1153 // inside an empty transcript view.1153 // inside an empty transcript view.
1154-func TestSwarmTranscriptEscClosesView(t *testing.T) {1154+func TestSupervisorTranscriptEscClosesView(t *testing.T) {
1155- rows := []swarm.AgentSnapshot{{1155+ rows := []subagents.AgentSnapshot{{
1156- ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(),1156+ ID: "agent-1", Task: "x", Status: subagents.StatusRunning, Started: time.Now(),
1157 Lines: []string{"hi"},1157 Lines: []string{"hi"},
1158 }}1158 }}
1159- d := newSwarmDialog()1159+ d := newSubagentsDialog()
1160 d.Open(staticSnapshots(rows...), nil, nil, nil,1160 d.Open(staticSnapshots(rows...), nil, nil, nil,
1161 func(string, string) error { return nil },1161 func(string, string) error { return nil },
1162 nil, "")1162 nil, "")
@@ -1175,17 +1175,17 @@ func TestSwarmTranscriptEscClosesView(t *testing.T) {
1175 }1175 }
1176 }1176 }
1177 1177
1178-// TestSwarmTranscriptNonRunningAgentNoEditor pins the safety net:1178+// TestSupervisorTranscriptNonRunningAgentNoEditor pins the safety net:
1179 // transcripts for done / failed / killed agents must NOT auto-open1179 // transcripts for done / failed / killed agents must NOT auto-open
1180 // an editor (the agent's inbox isn't listening, so a send would1180 // an editor (the agent's inbox isn't listening, so a send would
1181 // just bounce). The view shows the transcript plus a hint about1181 // just bounce). The view shows the transcript plus a hint about
1182 // resuming instead.1182 // resuming instead.
1183-func TestSwarmTranscriptNonRunningAgentNoEditor(t *testing.T) {1183+func TestSupervisorTranscriptNonRunningAgentNoEditor(t *testing.T) {
1184- rows := []swarm.AgentSnapshot{{1184+ rows := []subagents.AgentSnapshot{{
1185- ID: "agent-1", Task: "x", Status: swarm.StatusDone, Started: time.Now(),1185+ ID: "agent-1", Task: "x", Status: subagents.StatusDone, Started: time.Now(),
1186 Lines: []string{"all done"},1186 Lines: []string{"all done"},
1187 }}1187 }}
1188- d := newSwarmDialog()1188+ d := newSubagentsDialog()
1189 d.Open(staticSnapshots(rows...), nil, nil, nil,1189 d.Open(staticSnapshots(rows...), nil, nil, nil,
1190 func(string, string) error { return nil },1190 func(string, string) error { return nil },
1191 nil, "")1191 nil, "")
@@ -1200,19 +1200,19 @@ func TestSwarmTranscriptNonRunningAgentNoEditor(t *testing.T) {
1200 }1200 }
1201 }1201 }
1202 1202
1203-// TestSwarmTranscriptTickRefreshWhileEditing pins the streaming1203+// TestSupervisorTranscriptTickRefreshWhileEditing pins the streaming
1204 // fix: with the always-on editor in transcript view, the host's1204 // fix: with the always-on editor in transcript view, the host's
1205 // animation tick MUST still call us so streaming agent output1205 // animation tick MUST still call us so streaming agent output
1206 // renders. Without this, sending a message and watching the reply1206 // renders. Without this, sending a message and watching the reply
1207 // land silently failed because the prompting=true branch was1207 // land silently failed because the prompting=true branch was
1208 // suppressing redraws (like the modal editors do).1208 // suppressing redraws (like the modal editors do).
1209-func TestSwarmTranscriptTickRefreshWhileEditing(t *testing.T) {1209+func TestSupervisorTranscriptTickRefreshWhileEditing(t *testing.T) {
1210- rows := []swarm.AgentSnapshot{{1210+ rows := []subagents.AgentSnapshot{{
1211- ID: "agent-1", Task: "x", Status: swarm.StatusRunning,1211+ ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
1212 Activity: "thinking", Started: time.Now(),1212 Activity: "thinking", Started: time.Now(),
1213 Lines: []string{"hello"},1213 Lines: []string{"hello"},
1214 }}1214 }}
1215- d := newSwarmDialog()1215+ d := newSubagentsDialog()
1216 d.Open(staticSnapshots(rows...), nil, nil, nil,1216 d.Open(staticSnapshots(rows...), nil, nil, nil,
1217 func(string, string) error { return nil },1217 func(string, string) error { return nil },
1218 nil, "")1218 nil, "")
@@ -1227,15 +1227,15 @@ func TestSwarmTranscriptTickRefreshWhileEditing(t *testing.T) {
1227 }1227 }
1228 }1228 }
1229 1229
1230-// TestSwarmTranscriptTickRefreshModalSuppressed pins the inverse:1230+// TestSupervisorTranscriptTickRefreshModalSuppressed pins the inverse:
1231 // the list-view `p` modal (prompting without viewing) still1231 // the list-view `p` modal (prompting without viewing) still
1232 // suppresses tick like the other modal editors, so the cursor1232 // suppresses tick like the other modal editors, so the cursor
1233 // blink works cleanly while composing a one-off prompt.1233 // blink works cleanly while composing a one-off prompt.
1234-func TestSwarmTranscriptTickRefreshModalSuppressed(t *testing.T) {1234+func TestSupervisorTranscriptTickRefreshModalSuppressed(t *testing.T) {
1235- rows := []swarm.AgentSnapshot{{1235+ rows := []subagents.AgentSnapshot{{
1236- ID: "agent-1", Status: swarm.StatusRunning, Started: time.Now(),1236+ ID: "agent-1", Status: subagents.StatusRunning, Started: time.Now(),
1237 }}1237 }}
1238- d := newSwarmDialog()1238+ d := newSubagentsDialog()
1239 d.Open(staticSnapshots(rows...), nil, nil, nil,1239 d.Open(staticSnapshots(rows...), nil, nil, nil,
1240 func(string, string) error { return nil },1240 func(string, string) error { return nil },
1241 nil, "")1241 nil, "")
@@ -1250,18 +1250,18 @@ func TestSwarmTranscriptTickRefreshModalSuppressed(t *testing.T) {
1250 }1250 }
1251 }1251 }
1252 1252
1253-// TestSwarmTranscriptBusySpinnerRenders proves the inline spinner1253+// TestSupervisorTranscriptBusySpinnerRenders proves the inline spinner
1254 // appears above the always-on editor while the agent is busy and1254 // appears above the always-on editor while the agent is busy and
1255 // disappears when it goes idle. This is the /btw-style "something1255 // disappears when it goes idle. This is the /btw-style "something
1256 // is happening" feedback the user was missing.1256 // is happening" feedback the user was missing.
1257-func TestSwarmTranscriptBusySpinnerRenders(t *testing.T) {1257+func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
1258- cur := []swarm.AgentSnapshot{{1258+ cur := []subagents.AgentSnapshot{{
1259- ID: "agent-1", Task: "x", Status: swarm.StatusRunning,1259+ ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
1260 Activity: "thinking", Started: time.Now(),1260 Activity: "thinking", Started: time.Now(),
1261 Lines: []string{"hello"},1261 Lines: []string{"hello"},
1262 }}1262 }}
1263- snap := func() []swarm.AgentSnapshot { return cur }1263+ snap := func() []subagents.AgentSnapshot { return cur }
1264- d := newSwarmDialog()1264+ d := newSubagentsDialog()
1265 d.Open(snap, nil, nil, nil,1265 d.Open(snap, nil, nil, nil,
1266 func(string, string) error { return nil },1266 func(string, string) error { return nil },
1267 nil, "")1267 nil, "")
@@ -1289,9 +1289,9 @@ func TestSwarmTranscriptBusySpinnerRenders(t *testing.T) {
1289 }1289 }
1290 1290
1291 func TestStatusLabelCoversAll(t *testing.T) {1291 func TestStatusLabelCoversAll(t *testing.T) {
1292- for _, s := range []swarm.Status{1292+ for _, s := range []subagents.Status{
1293- swarm.StatusPending, swarm.StatusRunning,1293+ subagents.StatusPending, subagents.StatusRunning,
1294- swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,1294+ subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled,
1295 } {1295 } {
1296 if statusLabel(s) == "" {1296 if statusLabel(s) == "" {
1297 t.Fatalf("no label for %s", s)1297 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 {
59 return SaveConfig(cfg)59 return SaveConfig(cfg)
60 }60 }
61 61
62-func (configSettingsStore) SetAutoSwarm(enabled bool) error {62+func (configSettingsStore) SetAutoSubagents(enabled bool) error {
63 cfg, err := LoadConfig()63 cfg, err := LoadConfig()
64 if err != nil {64 if err != nil {
65 return err65 return err
66 }66 }
67- cfg.AutoSwarmEnabled = &enabled67+ cfg.AutoSubagentsEnabled = &enabled
68 return SaveConfig(cfg)68 return SaveConfig(cfg)
69 }69 }
70 70
@@ -212,24 +212,26 @@ func (configSettingsStore) SetTheme(name string) error {
212 return SaveConfig(cfg)212 return SaveConfig(cfg)
213 }213 }
214 214
215-// AutoSwarmEnabled reads the current auto-swarm flag from config.215+// AutoSubagentsEnabled reads the current auto-subagents flag from config.
216-// Used by the swarm_spawn tool at call time to gate execution.216+// The build/CLI startup path uses it when initially injecting
217-func AutoSwarmEnabled() bool {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 {
218 cfg, err := LoadConfig()220 cfg, err := LoadConfig()
219 if err != nil {221 if err != nil {
220 return false222 return false
221 }223 }
222- return cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled224+ return cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled
223 }225 }
224 226
225-// AutoSwarmSystemAddendum is appended to the system prompt when227+// AutoSubagentsSystemAddendum is appended to the system prompt when
226-// auto-swarm is enabled, so the model knows it may delegate to228+// auto-subagents is enabled, so the model knows it may delegate to
227 // background sub-agents without the user having to mention the tool229 // background sub-agents without the user having to mention the tool
228 // by name. Kept short so it doesn't bloat the cached prompt prefix.230 // 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.
230 232
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.
232 234
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.
234 236
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 swarm1+package subagents
2 2
3 import (3 import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "os"
4 "path/filepath"8 "path/filepath"
9+ "strings"
5 "testing"10 "testing"
6 "time"11 "time"
7 )12 )
8 13
9 // TestEventLogAppendAndRead is the simplest possible invariant:14 // TestEventLogAppendAndRead is the simplest possible invariant:
10 // what we write goes back through ReadEventLog exactly, including15 // what we write goes back through ReadEventLog exactly, including
11-// the flat field shape and the timestamp.16+// the payload fields and the timestamp.
12 func TestEventLogAppendAndRead(t *testing.T) {17 func TestEventLogAppendAndRead(t *testing.T) {
13 path := filepath.Join(t.TempDir(), "events.jsonl")18 path := filepath.Join(t.TempDir(), "events.jsonl")
14 log, err := OpenEventLog(path)19 log, err := OpenEventLog(path)
@@ -43,11 +48,118 @@ func TestEventLogAppendAndRead(t *testing.T) {
43 }48 }
44 }49 }
45 50
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+
46 // TestReadEventLogIgnoresGarbage documents that one malformed line76 // TestReadEventLogIgnoresGarbage documents that one malformed line
47 // doesn't break the whole replay. The runner's responsibility is to77 // doesn't break the whole replay. The runner's responsibility is to
48 // only write well-formed events, but a partial crash could leave78 // only write well-formed events, but a partial crash could leave
49 // a half-written line; we don't want that to prevent the dashboard79 // a half-written line; we don't want that to prevent the dashboard
50 // from showing the rest.80 // 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+
51 func TestReadEventLogIgnoresGarbage(t *testing.T) {163 func TestReadEventLogIgnoresGarbage(t *testing.T) {
52 path := filepath.Join(t.TempDir(), "events.jsonl")164 path := filepath.Join(t.TempDir(), "events.jsonl")
53 log, err := OpenEventLog(path)165 log, err := OpenEventLog(path)
@@ -202,6 +314,38 @@ func TestFollowerEmitsNewEvents(t *testing.T) {
202 }314 }
203 }315 }
204 316
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+
205 func drain(ch <-chan Event) {349 func drain(ch <-chan Event) {
206 for {350 for {
207 select {351 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 swarm1+package subagents
2 2
3 import (3 import (
4 "context"4 "context"
@@ -6,7 +6,7 @@ import (
6 "testing"6 "testing"
7 )7 )
8 8
9-func TestSwarmFastModePropagatesToChild(t *testing.T) {9+func TestSupervisorFastModePropagatesToChild(t *testing.T) {
10 root := t.TempDir()10 root := t.TempDir()
11 f := New(Config{11 f := New(Config{
12 Root: root,12 Root: root,
@@ -103,9 +103,9 @@ func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) {
103 }103 }
104 }104 }
105 105
106-func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) {106+func TestSupervisorReloadPreservesExplicitFastModeOff(t *testing.T) {
107 root := t.TempDir()107 root := t.TempDir()
108- newSwarm := func(fastMode bool) *Swarm {108+ newSupervisor := func(fastMode bool) *Supervisor {
109 return New(Config{109 return New(Config{
110 Root: root,110 Root: root,
111 RepoRoot: root,111 RepoRoot: root,
@@ -119,7 +119,7 @@ func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) {
119 })119 })
120 }120 }
121 121
122- f := newSwarm(false)122+ f := newSupervisor(false)
123 a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x"})123 a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x"})
124 if err != nil {124 if err != nil {
125 t.Fatalf("spawn: %v", err)125 t.Fatalf("spawn: %v", err)
@@ -132,7 +132,7 @@ func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) {
132 }132 }
133 a.Wait()133 a.Wait()
134 134
135- g := newSwarm(true)135+ g := newSupervisor(true)
136 if loaded, errs := g.Reload(); loaded != 1 || len(errs) != 0 {136 if loaded, errs := g.Reload(); loaded != 1 || len(errs) != 0 {
137 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)137 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
138 }138 }
@@ -141,15 +141,15 @@ func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) {
141 }141 }
142 }142 }
143 143
144-func TestSwarmAgentArgsOmitFastModeWhenUnset(t *testing.T) {144+func TestSubagentWorkerArgsOmitFastModeWhenUnset(t *testing.T) {
145- args := swarmAgentArgs(swarmAgentArgsOpts{Exe: "/zot", Dir: "/wt", SessionPath: "/s", InboxPath: "/i"})145+ args := subagentWorkerArgs(subagentWorkerArgsOpts{Exe: "/zot", Dir: "/wt", SessionPath: "/s", InboxPath: "/i"})
146 if strings.Contains(strings.Join(args, " "), "--fast-mode") || strings.Contains(strings.Join(args, " "), "--no-fast-mode") {146 if strings.Contains(strings.Join(args, " "), "--fast-mode") || strings.Contains(strings.Join(args, " "), "--no-fast-mode") {
147 t.Fatalf("child args = %v, want fast mode omitted when unset", args)147 t.Fatalf("child args = %v, want fast mode omitted when unset", args)
148 }148 }
149 }149 }
150 150
151-func TestSwarmAgentArgsExplicitlyDisablesFastMode(t *testing.T) {151+func TestSubagentWorkerArgsExplicitlyDisablesFastMode(t *testing.T) {
152- args := swarmAgentArgs(swarmAgentArgsOpts{152+ args := subagentWorkerArgs(subagentWorkerArgsOpts{
153 Exe: "/zot",153 Exe: "/zot",
154 Dir: "/wt",154 Dir: "/wt",
155 SessionPath: "/s",155 SessionPath: "/s",
R packages/agent/swarm/inbox.go → packages/agent/subagents/inbox.go +126 -74 renamed, 57% similar
@@ -1,10 +1,9 @@
1-package swarm1+package subagents
2 2
3 import (3 import (
4 "bufio"4 "bufio"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7- "io"
8 "net"7 "net"
9 "os"8 "os"
10 "path/filepath"9 "path/filepath"
@@ -13,28 +12,16 @@ import (
13 "time"12 "time"
14 )13 )
15 14
16-// Inbox is the supervisor-side handle on a per-agent unix socket15+// Inbox is the supervisor-side handle on a per-agent unix socket.
17-// that the parent zot uses to send follow-up prompts (and other16+// The wire format is newline-delimited, versioned JSON commands. The child
18-// control lines) to a running swarm agent.17+// listens on the same socket via Listener (below); the parent never reads
19-//18+// from it.
20-// Protocol on the wire: one UTF-8 message per line, newline-
21-// terminated. Three message kinds:
22-//
23-// user <text>... — append <text> as the next user turn
24-// cancel — cancel the agent's in-flight turn
25-// shutdown — graceful exit; child will write a final
26-// EvTurnEnd-equivalent JSON event and quit
27-//
28-// The child listens on the same socket via Listener (below) and
29-// translates the messages into core.Agent.Prompt calls. The
30-// parent never reads from the socket — it's a one-way channel
31-// driven by the supervisor.
32 //19 //
33 // Why a socket and not a FIFO: FIFOs are POSIX-only and have20 // Why a socket and not a FIFO: FIFOs are POSIX-only and have
34 // awkward blocking-open semantics. A unix socket gives us:21 // awkward blocking-open semantics. A unix socket gives us:
35 //22 //
36 // - the ability to fail fast if no child is listening yet,23 // - 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;
38 // - clean shutdown semantics when the child closes the25 // - clean shutdown semantics when the child closes the
39 // listener;26 // listener;
40 // - works under tests with a temp dir; and27 // - works under tests with a temp dir; and
@@ -48,7 +35,7 @@ type Inbox struct {
48 }35 }
49 36
50 // NewInbox returns a handle that will dial the socket at path on37 // NewInbox returns a handle that will dial the socket at path on
51-// the first SendInput call. The socket file is expected to be38+// the first SendCommand call. The socket file is expected to be
52 // created by the child (Listener.Open below). This split lets the39 // created by the child (Listener.Open below). This split lets the
53 // parent build the Inbox before the child has booted; sends that40 // parent build the Inbox before the child has booted; sends that
54 // happen before the child is ready get a clear "not yet" error41 // 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} }
59 // pass the path to the child as a flag.46 // pass the path to the child as a flag.
60 func (b *Inbox) Path() string { return b.path }47 func (b *Inbox) Path() string { return b.path }
61 48
62-// SendInput writes msg to the inbox as one line. msg must already49+// SendCommand sends a versioned JSONL command.
63-// include the leading kind ("user ", "cancel", "shutdown") — the50+func (b *Inbox) SendCommand(command Envelope) error {
64-// caller chooses the shape so we don't proliferate methods. The51+ line, err := MarshalJSONL(command)
65-// socket is reused across calls; transient dial failures yield52+ if err != nil {
66-// ErrNotReady so the TUI can retry or report "agent not listening53+ return err
67-// yet" rather than crashing.54+ }
68-func (b *Inbox) SendInput(msg string) error {55+ return b.sendBytes(line)
56+}
57+
58+func (b *Inbox) sendBytes(data []byte) error {
69 b.mu.Lock()59 b.mu.Lock()
70 defer b.mu.Unlock()60 defer b.mu.Unlock()
71 if b.conn == nil {61 if b.conn == nil {
@@ -75,7 +65,7 @@ func (b *Inbox) SendInput(msg string) error {
75 }65 }
76 b.conn = c66 b.conn = c
77 }67 }
78- if _, err := io.WriteString(b.conn, msg+"\n"); err != nil {68+ if _, err := b.conn.Write(data); err != nil {
79 // Drop the connection so the next call redials. The69 // Drop the connection so the next call redials. The
80 // previous error is more informative than the redial's70 // previous error is more informative than the redial's
81 // would be, so surface this one.71 // would be, so surface this one.
@@ -100,10 +90,10 @@ func (b *Inbox) Close() error {
100 return nil90 return nil
101 }91 }
102 92
103-// ErrNotReady is returned by SendInput when the child hasn't yet93+// ErrNotReady is returned when the child hasn't yet
104 // opened its listener. Callers can retry after a short backoff;94 // opened its listener. Callers can retry after a short backoff;
105 // the TUI surfaces it as "agent <id> not listening yet".95 // 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")
107 97
108 // dialUnix wraps net.DialTimeout with a small retry window so the98 // dialUnix wraps net.DialTimeout with a small retry window so the
109 // happy path "child just started, supervisor sends right away"99 // happy path "child just started, supervisor sends right away"
@@ -113,7 +103,7 @@ var ErrNotReady = errors.New("swarm: agent inbox not ready")
113 // one is listening": either the socket file doesn't exist (child103 // one is listening": either the socket file doesn't exist (child
114 // hasn't booted) or it does exist but the dial was refused (the104 // hasn't booted) or it does exist but the dial was refused (the
115 // previous child exited and left a stale inode). Both are105 // previous child exited and left a stale inode). Both are
116-// recoverable by Swarm.Resume, and both should surface as the same106+// recoverable by Supervisor.Resume, and both should surface as the same
117 // user-facing hint rather than as a path-leaking error string.107 // user-facing hint rather than as a path-leaking error string.
118 func dialUnix(path string, timeout time.Duration) (net.Conn, error) {108 func dialUnix(path string, timeout time.Duration) (net.Conn, error) {
119 deadline := time.Now().Add(timeout)109 deadline := time.Now().Add(timeout)
@@ -135,7 +125,7 @@ func dialUnix(path string, timeout time.Duration) (net.Conn, error) {
135 if isNoListenerErr(lastErr) {125 if isNoListenerErr(lastErr) {
136 return nil, ErrNotReady126 return nil, ErrNotReady
137 }127 }
138- return nil, fmt.Errorf("swarm: dial %s: %w", path, lastErr)128+ return nil, fmt.Errorf("subagent: dial %s: %w", path, lastErr)
139 }129 }
140 130
141 // isNoListenerErr reports whether err means "the socket file exists131 // isNoListenerErr reports whether err means "the socket file exists
@@ -147,7 +137,31 @@ func isNoListenerErr(err error) bool {
147 return errors.Is(err, syscall.ECONNREFUSED)137 return errors.Is(err, syscall.ECONNREFUSED)
148 }138 }
149 139
150-// Listener is the child-side end of the inbox. The swarm-agent140+// 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
151 // daemon mode constructs one with Listen, then iterates Lines165 // daemon mode constructs one with Listen, then iterates Lines
152 // to receive supervisor messages. Designed so a single goroutine166 // to receive supervisor messages. Designed so a single goroutine
153 // in the child can drive prompting without juggling raw net code.167 // in the child can drive prompting without juggling raw net code.
@@ -158,35 +172,53 @@ type Listener struct {
158 // only expects one supervisor (the parent zot), so newer172 // only expects one supervisor (the parent zot), so newer
159 // connections preempt older ones — the previous parent173 // connections preempt older ones — the previous parent
160 // presumably crashed.174 // presumably crashed.
161- mu sync.Mutex175+ mu sync.Mutex
162- active net.Conn176+ active net.Conn
163- out chan string177+ conns map[net.Conn]struct{}
164- done chan struct{}178+ out chan string
165- wg sync.WaitGroup179+ done chan struct{}
180+ wg sync.WaitGroup
181+ closed bool
182+ closeOnce sync.Once
166 }183 }
167 184
168 // Listen creates the socket at path and starts accepting. The185 // Listen creates the socket at path and starts accepting. The
169 // caller must call Close to remove the socket file on shutdown.186 // caller must call Close to remove the socket file on shutdown.
170-// Returns the listener even if a stale socket exists from a187+// A live endpoint is preserved and rejected; only a refused probe
171-// previous run: we unlink first, mirroring how most unix daemons188+// is treated as a stale socket eligible for removal.
172-// behave.
173 func Listen(path string) (*Listener, error) {189 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 {
175 return nil, fmt.Errorf("inbox dir: %w", err)192 return nil, fmt.Errorf("inbox dir: %w", err)
176 }193 }
177- // Best-effort cleanup of a stale socket. If the parent194+ if err := os.Chmod(dir, 0o700); err != nil {
178- // process is still alive and using it, Listen will fail195+ return nil, fmt.Errorf("inbox dir permissions: %w", err)
179- // below and the caller surfaces that as a real conflict.196+ }
180- _ = os.Remove(path)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+ }
181 ln, err := net.Listen("unix", path)208 ln, err := net.Listen("unix", path)
182 if err != nil {209 if err != nil {
183 return nil, fmt.Errorf("inbox listen: %w", err)210 return nil, fmt.Errorf("inbox listen: %w", err)
184 }211 }
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)
185 l := &Listener{216 l := &Listener{
186- path: path,217+ path: path,
187- ln: ln,218+ ln: ln,
188- out: make(chan string, 16),219+ conns: make(map[net.Conn]struct{}),
189- done: make(chan struct{}),220+ out: make(chan string, 16),
221+ done: make(chan struct{}),
190 }222 }
191 go l.acceptLoop()223 go l.acceptLoop()
192 return l, nil224 return l, nil
@@ -199,19 +231,17 @@ func (l *Listener) Lines() <-chan string { return l.out }
199 // Close stops accepting, drops the active connection, removes231 // Close stops accepting, drops the active connection, removes
200 // the socket file, and closes Lines. Idempotent.232 // the socket file, and closes Lines. Idempotent.
201 func (l *Listener) Close() error {233 func (l *Listener) Close() error {
202- select {234+ l.closeOnce.Do(func() {
203- case <-l.done:235+ l.mu.Lock()
204- return nil236+ l.closed = true
205- default:
206 close(l.done)237 close(l.done)
207- }238+ for conn := range l.conns {
208- l.mu.Lock()239+ _ = conn.Close()
209- if l.active != nil {240+ }
210- _ = l.active.Close()241+ l.mu.Unlock()
211- }242+ _ = l.ln.Close()
212- l.mu.Unlock()243+ _ = os.Remove(l.path)
213- _ = l.ln.Close()244+ })
214- _ = os.Remove(l.path)
215 return nil245 return nil
216 }246 }
217 247
@@ -233,12 +263,14 @@ func (l *Listener) acceptLoop() {
233 }263 }
234 }264 }
235 l.mu.Lock()265 l.mu.Lock()
236- if l.active != nil {266+ if l.closed {
237- _ = l.active.Close()267+ l.mu.Unlock()
268+ _ = c.Close()
269+ return
238 }270 }
239- l.active = c271+ l.conns[c] = struct{}{}
240- l.mu.Unlock()
241 l.wg.Add(1)272 l.wg.Add(1)
273+ l.mu.Unlock()
242 go func() {274 go func() {
243 defer l.wg.Done()275 defer l.wg.Done()
244 l.readLoop(c)276 l.readLoop(c)
@@ -247,24 +279,44 @@ func (l *Listener) acceptLoop() {
247 }279 }
248 280
249 func (l *Listener) readLoop(c net.Conn) {281 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+
250 br := bufio.NewReader(c)292 br := bufio.NewReader(c)
293+ firstLine := true
251 for {294 for {
252 line, err := br.ReadString('\n')295 line, err := br.ReadString('\n')
253 if line != "" {296 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+ }
254 select {313 select {
255- case l.out <- trimRightNL(line):314+ case l.out <- message:
256 case <-l.done:315 case <-l.done:
257- _ = c.Close()
258 return316 return
259 }317 }
260 }318 }
261 if err != nil {319 if err != nil {
262- _ = c.Close()
263- l.mu.Lock()
264- if l.active == c {
265- l.active = nil
266- }
267- l.mu.Unlock()
268 return320 return
269 }321 }
270 }322 }
R packages/agent/swarm/inbox_test.go → packages/agent/subagents/inbox_test.go +33 -19 renamed, 79% similar
@@ -1,4 +1,4 @@
1-package swarm1+package subagents
2 2
3 import (3 import (
4 "errors"4 "errors"
@@ -20,7 +20,7 @@ import (
20 func shortSocketDir(t *testing.T) string {20 func shortSocketDir(t *testing.T) string {
21 t.Helper()21 t.Helper()
22 if runtime.GOOS == "windows" {22 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")
24 }24 }
25 dir, err := os.MkdirTemp("/tmp", "zot-in-")25 dir, err := os.MkdirTemp("/tmp", "zot-in-")
26 if err != nil {26 if err != nil {
@@ -41,26 +41,40 @@ func TestInboxRoundTrip(t *testing.T) {
41 }41 }
42 defer ln.Close()42 defer ln.Close()
43 43
44- got := collectFor(t, ln.Lines(), 3, time.Second)44+ got := collectFor(t, ln.Lines(), 4, time.Second)
45 inbox := NewInbox(path)45 inbox := NewInbox(path)
46 defer inbox.Close()46 defer inbox.Close()
47 47
48 want := []string{"user hello", "user world", "cancel"}48 want := []string{"user hello", "user world", "cancel"}
49 for _, msg := range want {49 for _, msg := range want {
50- if err := inbox.SendInput(msg); err != nil {50+ if err := inbox.sendBytes([]byte(msg + "\n")); err != nil {
51- t.Fatalf("SendInput(%q): %v", msg, err)51+ t.Fatalf("sendBytes(%q): %v", msg, err)
52 }52 }
53 }53 }
54+ if err := inbox.SendCommand(NewCommand(CommandTurnStart, "agent-1", "turn-1", TurnStartPayload{Prompt: "round trip"})); err != nil {
55+ t.Fatalf("SendCommand: %v", err)
56+ }
54 57
55 gotLines := <-got58 gotLines := <-got
56- if len(gotLines) != len(want) {59+ if len(gotLines) != len(want)+1 {
57- t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want), gotLines)60+ t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want)+1, gotLines)
58 }61 }
59 for i, w := range want {62 for i, w := range want {
60 if gotLines[i] != w {63 if gotLines[i] != w {
61 t.Errorf("line %d = %q; want %q", i, gotLines[i], w)64 t.Errorf("line %d = %q; want %q", i, gotLines[i], w)
62 }65 }
63 }66 }
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+ }
64 }78 }
65 79
66 // TestInboxNotReady regression-tests the explicit "child hasn't80 // TestInboxNotReady regression-tests the explicit "child hasn't
@@ -70,19 +84,19 @@ func TestInboxNotReady(t *testing.T) {
70 path := filepath.Join(shortSocketDir(t), "in.sock")84 path := filepath.Join(shortSocketDir(t), "in.sock")
71 inbox := NewInbox(path)85 inbox := NewInbox(path)
72 defer inbox.Close()86 defer inbox.Close()
73- err := inbox.SendInput("user hi")87+ err := inbox.sendBytes([]byte("user hi\n"))
74 if err == nil {88 if err == nil {
75- t.Fatal("SendInput on missing socket: want error")89+ t.Fatal("sendBytes on missing socket: want error")
76 }90 }
77 if !errors.Is(err, ErrNotReady) {91 if !errors.Is(err, ErrNotReady) {
78- t.Fatalf("SendInput err = %v; want ErrNotReady", err)92+ t.Fatalf("sendBytes err = %v; want ErrNotReady", err)
79 }93 }
80 }94 }
81 95
82 // TestIsNoListenerErrMatchesECONNREFUSED documents the contract96 // TestIsNoListenerErrMatchesECONNREFUSED documents the contract
83 // that the user's screenshot regression depends on: any dial error97 // that the user's screenshot regression depends on: any dial error
84 // whose root cause is "the kernel refused the connect because nobody98 // 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,
86 // so the TUI shows "agent not accepting input (press R to resume)"100 // so the TUI shows "agent not accepting input (press R to resume)"
87 // instead of leaking the unix-socket path. We pattern-match via101 // instead of leaking the unix-socket path. We pattern-match via
88 // errors.Is on syscall.ECONNREFUSED rather than string-matching the102 // errors.Is on syscall.ECONNREFUSED rather than string-matching the
@@ -104,7 +118,7 @@ func TestIsNoListenerErrMatchesECONNREFUSED(t *testing.T) {
104 }118 }
105 119
106 // TestInboxNewSupervisorPreemptsOld documents that only one120 // TestInboxNewSupervisorPreemptsOld documents that only one
107-// supervisor connection at a time is honoured. The swarm design121+// supervisor connection at a time is honoured. The subagent design
108 // assumes a single parent zot owns each agent; if a second122 // assumes a single parent zot owns each agent; if a second
109 // parent dials, the listener boots the first one. The first123 // parent dials, the listener boots the first one. The first
110 // supervisor's already-delivered messages still land in the124 // supervisor's already-delivered messages still land in the
@@ -119,7 +133,7 @@ func TestInboxNewSupervisorPreemptsOld(t *testing.T) {
119 defer ln.Close()133 defer ln.Close()
120 134
121 first := NewInbox(path)135 first := NewInbox(path)
122- if err := first.SendInput("user one"); err != nil {136+ if err := first.sendBytes([]byte("user one\n")); err != nil {
123 t.Fatalf("first send: %v", err)137 t.Fatalf("first send: %v", err)
124 }138 }
125 // Wait for the listener to absorb "user one" before dialing the139 // Wait for the listener to absorb "user one" before dialing the
@@ -130,7 +144,7 @@ func TestInboxNewSupervisorPreemptsOld(t *testing.T) {
130 144
131 second := NewInbox(path)145 second := NewInbox(path)
132 defer second.Close()146 defer second.Close()
133- if err := second.SendInput("user two"); err != nil {147+ if err := second.sendBytes([]byte("user two\n")); err != nil {
134 t.Fatalf("second send: %v", err)148 t.Fatalf("second send: %v", err)
135 }149 }
136 150
@@ -140,17 +154,17 @@ func TestInboxNewSupervisorPreemptsOld(t *testing.T) {
140 }154 }
141 155
142 // The original supervisor's connection was preempted by the156 // The original supervisor's connection was preempted by the
143- // listener accepting the second dial. The next SendInput on the157+ // listener accepting the second dial. The next sendBytes on the
144 // first handle sees a broken pipe and drops the cached connection;158 // first handle sees a broken pipe and drops the cached connection;
145 // the call after that redials successfully. Both behaviours159 // the call after that redials successfully. Both behaviours
146 // matter: the broken-pipe error must surface (so a real160 // matter: the broken-pipe error must surface (so a real
147 // supervisor knows it lost ownership) AND the next call must161 // supervisor knows it lost ownership) AND the next call must
148 // recover so a flaky reconnect doesn't permanently wedge the162 // recover so a flaky reconnect doesn't permanently wedge the
149 // inbox.163 // inbox.
150- if err := first.SendInput("user three"); err == nil {164+ if err := first.sendBytes([]byte("user three\n")); err == nil {
151 t.Fatal("expected broken-pipe error on stale connection")165 t.Fatal("expected broken-pipe error on stale connection")
152 }166 }
153- if err := first.SendInput("user four"); err != nil {167+ if err := first.sendBytes([]byte("user four\n")); err != nil {
154 t.Fatalf("first redial after eviction: %v", err)168 t.Fatalf("first redial after eviction: %v", err)
155 }169 }
156 }170 }
@@ -197,7 +211,7 @@ func collectFor(t *testing.T, ch <-chan string, n int, timeout time.Duration) <-
197 return out211 return out
198 }212 }
199 213
200-// TestInboxConcurrentSends serialises the SendInput call so two214+// TestInboxConcurrentSends serialises the sendBytes call so two
201 // goroutines writing at once still produce two whole lines, never215 // goroutines writing at once still produce two whole lines, never
202 // a tearing of bytes between them.216 // a tearing of bytes between them.
203 func TestInboxConcurrentSends(t *testing.T) {217 func TestInboxConcurrentSends(t *testing.T) {
@@ -216,7 +230,7 @@ func TestInboxConcurrentSends(t *testing.T) {
216 for i := 0; i < n; i++ {230 for i := 0; i < n; i++ {
217 go func(i int) {231 go func(i int) {
218 defer wg.Done()232 defer wg.Done()
219- if err := inbox.SendInput("user msg"); err != nil {233+ if err := inbox.sendBytes([]byte("user msg\n")); err != nil {
220 t.Errorf("send %d: %v", i, err)234 t.Errorf("send %d: %v", i, err)
221 }235 }
222 }(i)236 }(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 swarm1+package subagents
2 2
3 import (3 import (
4 "context"4 "context"
@@ -64,7 +64,7 @@ func TestSpawnWritesMetaJSON(t *testing.T) {
64 }64 }
65 65
66 // TestReloadRebuildsDetachedAgents simulates a zot restart by spawning66 // TestReloadRebuildsDetachedAgents simulates a zot restart by spawning
67-// in one Swarm, throwing it away, then opening a fresh Swarm against67+// in one Supervisor, throwing it away, then opening a fresh Supervisor against
68 // the same root and calling Reload. The user-visible state — id,68 // the same root and calling Reload. The user-visible state — id,
69 // task, branch, dir — must come back, and status must be Detached so69 // task, branch, dir — must come back, and status must be Detached so
70 // the dashboard can offer resume.70 // the dashboard can offer resume.
@@ -133,6 +133,89 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) {
133 }133 }
134 }134 }
135 135
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+
136 // TestReloadIsIdempotent calls Reload twice in a row and asserts the219 // TestReloadIsIdempotent calls Reload twice in a row and asserts the
137 // second call neither duplicates rows nor errors.220 // second call neither duplicates rows nor errors.
138 func TestReloadIsIdempotent(t *testing.T) {221 func TestReloadIsIdempotent(t *testing.T) {
@@ -165,7 +248,7 @@ func TestReloadIsIdempotent(t *testing.T) {
165 // TestReloadReplaysTranscriptFromEventLog drops a curated events.jsonl248 // TestReloadReplaysTranscriptFromEventLog drops a curated events.jsonl
166 // next to a meta.json and checks that the transcript surfaces in the249 // next to a meta.json and checks that the transcript surfaces in the
167 // reloaded agent. This is the user-facing payoff of Reload: opening250 // 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.
169 func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {252 func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
170 root := t.TempDir()253 root := t.TempDir()
171 id := "alpha-9"254 id := "alpha-9"
@@ -308,6 +391,7 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
308 if a.Status() != StatusKilled {391 if a.Status() != StatusKilled {
309 t.Fatalf("post-stop status = %q; want killed", a.Status())392 t.Fatalf("post-stop status = %q; want killed", a.Status())
310 }393 }
394+ previousAttempt := a.Snapshot().Attempt
311 395
312 // Now resume.396 // Now resume.
313 a2, err := f.Resume(context.Background(), a.ID)397 a2, err := f.Resume(context.Background(), a.ID)
@@ -326,6 +410,9 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
326 if a2.InboxPath != originalInbox {410 if a2.InboxPath != originalInbox {
327 t.Errorf("resume changed inbox path: %s vs %s", a2.InboxPath, originalInbox)411 t.Errorf("resume changed inbox path: %s vs %s", a2.InboxPath, originalInbox)
328 }412 }
413+ if got := a2.Snapshot().Attempt; got != previousAttempt+1 {
414+ t.Errorf("resume attempt = %d; want %d", got, previousAttempt+1)
415+ }
329 // Two runner invocations: spawn + resume.416 // Two runner invocations: spawn + resume.
330 deadline = time.Now().Add(time.Second)417 deadline = time.Now().Add(time.Second)
331 for atomic.LoadInt32(&runsDone) < 2 && time.Now().Before(deadline) {418 for atomic.LoadInt32(&runsDone) < 2 && time.Now().Before(deadline) {
@@ -344,9 +431,9 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
344 }431 }
345 }432 }
346 433
347-func TestResumeRecalculatesLegacyStateInboxPath(t *testing.T) {434+func TestResumeRecalculatesInvalidStateInboxPath(t *testing.T) {
348 if runtime.GOOS == "windows" {435 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")
350 }437 }
351 root := t.TempDir()438 root := t.TempDir()
352 f := New(Config{439 f := New(Config{
@@ -355,21 +442,21 @@ func TestResumeRecalculatesLegacyStateInboxPath(t *testing.T) {
355 return RunnerFunc(func(context.Context, Sink) error { return nil })442 return RunnerFunc(func(context.Context, Sink) error { return nil })
356 },443 },
357 })444 })
358- a, err := f.Spawn(context.Background(), "legacy inbox")445+ a, err := f.Spawn(context.Background(), "invalid inbox")
359 if err != nil {446 if err != nil {
360 t.Fatal(err)447 t.Fatal(err)
361 }448 }
362 a.Wait()449 a.Wait()
363 450
364- legacyPath := filepath.Join(root, "agents", a.ID, "in.sock")451+ invalidPath := filepath.Join(root, "agents", a.ID, "in.sock")
365- a.InboxPath = legacyPath452+ a.InboxPath = invalidPath
366 a2, err := f.Resume(context.Background(), a.ID)453 a2, err := f.Resume(context.Background(), a.ID)
367 if err != nil {454 if err != nil {
368 t.Fatalf("resume: %v", err)455 t.Fatalf("resume: %v", err)
369 }456 }
370 a2.Wait()457 a2.Wait()
371- if a2.InboxPath == legacyPath {458+ if a2.InboxPath == invalidPath {
372- t.Fatalf("resume kept legacy state-directory inbox path %q", legacyPath)459+ t.Fatalf("resume kept state-directory inbox path %q", invalidPath)
373 }460 }
374 if strings.HasPrefix(a2.InboxPath, root+string(filepath.Separator)) {461 if strings.HasPrefix(a2.InboxPath, root+string(filepath.Separator)) {
375 t.Fatalf("resumed inbox %q remains below state root %q", a2.InboxPath, root)462 t.Fatalf("resumed inbox %q remains below state root %q", a2.InboxPath, root)
@@ -478,7 +565,7 @@ func TestResumeAfterReload(t *testing.T) {
478 a.StopAll()565 a.StopAll()
479 ag.Wait()566 ag.Wait()
480 567
481- // Process B: a new Swarm against the same root.568+ // Process B: a new Supervisor against the same root.
482 resumed := make(chan struct{}, 1)569 resumed := make(chan struct{}, 1)
483 b := New(Config{570 b := New(Config{
484 Root: root, RepoRoot: root,571 Root: root, RepoRoot: root,
@@ -525,6 +612,183 @@ func TestResumeAfterReload(t *testing.T) {
525 t.Fatalf("resumed transcript missing fresh line: %v", a2.Transcript())612 t.Fatalf("resumed transcript missing fresh line: %v", a2.Transcript())
526 }613 }
527 614
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+
528 // TestSpawnReqPersistsModel confirms that the per-agent model792 // TestSpawnReqPersistsModel confirms that the per-agent model
529 // override is captured at Spawn time, surfaced via Snapshot, and793 // override is captured at Spawn time, surfaced via Snapshot, and
530 // written to meta.json so a later Reload + Resume reuses it.794 // written to meta.json so a later Reload + Resume reuses it.
@@ -570,7 +834,7 @@ func TestSpawnReqPersistsModel(t *testing.T) {
570 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)834 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)
571 }835 }
572 836
573- // Reload in a fresh Swarm and confirm the detached agent still837+ // Reload in a fresh Supervisor and confirm the detached agent still
574 // carries the model/provider so Resume can route the child838 // carries the model/provider so Resume can route the child
575 // subprocess back to the same model.839 // subprocess back to the same model.
576 g := New(Config{840 g := New(Config{
@@ -603,12 +867,12 @@ func TestSpawnReqPersistsModel(t *testing.T) {
603 resumed.Wait()867 resumed.Wait()
604 }868 }
605 869
606-// TestSwarmAgentArgsIncludesModelFlags pins the argv contract that870+// TestSubagentWorkerArgsIncludesModelFlags pins the argv contract that
607 // connects the supervisor's per-agent model override to the child's871 // connects the supervisor's per-agent model override to the child's
608 // --model / --provider flag set. Adding a new path to argv without872 // --model / --provider flag set. Adding a new path to argv without
609 // updating this assertion is the failure mode this catches.873 // updating this assertion is the failure mode this catches.
610-func TestSwarmAgentArgsIncludesModelFlags(t *testing.T) {874+func TestSubagentWorkerArgsIncludesModelFlags(t *testing.T) {
611- args := swarmAgentArgs(swarmAgentArgsOpts{875+ args := subagentWorkerArgs(subagentWorkerArgsOpts{
612 Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",876 Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",
613 Task: "do x", Model: "gpt-5", Provider: "openai",877 Task: "do x", Model: "gpt-5", Provider: "openai",
614 })878 })
@@ -634,14 +898,14 @@ func TestSwarmAgentArgsIncludesModelFlags(t *testing.T) {
634 898
635 // TestStopOnDetachedAgentIsNoopAndDoesNotPanic regression-tests the899 // TestStopOnDetachedAgentIsNoopAndDoesNotPanic regression-tests the
636 // segfault that hit when the user pressed 'k' on a reloaded900 // 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(),
638 // but buildDetachedAgent never assigns a cancel func because there's902 // but buildDetachedAgent never assigns a cancel func because there's
639 // no in-process runner to cancel. The fix is to short-circuit Stop903 // no in-process runner to cancel. The fix is to short-circuit Stop
640 // for StatusDetached and — belt-and-braces — nil-check a.cancel.904 // for StatusDetached and — belt-and-braces — nil-check a.cancel.
641 func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) {905 func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) {
642 root := t.TempDir()906 root := t.TempDir()
643 // Build a detached agent the same way Reload does: drop a907 // 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.
645 id := "alpha-1"909 id := "alpha-1"
646 stateDir := filepath.Join(root, "agents", id)910 stateDir := filepath.Join(root, "agents", id)
647 if err := os.MkdirAll(stateDir, 0o755); err != nil {911 if err := os.MkdirAll(stateDir, 0o755); err != nil {
@@ -666,7 +930,7 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) {
666 }930 }
667 a := f.Get(id)931 a := f.Get(id)
668 if a == nil {932 if a == nil {
669- t.Fatal("detached agent missing from swarm")933+ t.Fatal("detached agent missing from subagent")
670 }934 }
671 if a.Status() != StatusDetached {935 if a.Status() != StatusDetached {
672 t.Fatalf("setup: agent status = %q; want detached", a.Status())936 t.Fatalf("setup: agent status = %q; want detached", a.Status())
@@ -720,15 +984,46 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) {
720 t.Fatalf("state dir should be gone after remove; got %v", err)984 t.Fatalf("state dir should be gone after remove; got %v", err)
721 }985 }
722 986
723- // A fresh Swarm + Reload should find nothing.987+ // A fresh Supervisor + Reload should find nothing.
724 g := New(Config{Root: root, RepoRoot: root})988 g := New(Config{Root: root, RepoRoot: root})
725 if loaded, _ := g.Reload(); loaded != 0 {989 if loaded, _ := g.Reload(); loaded != 0 {
726 t.Fatalf("reload after remove loaded=%d; want 0", loaded)990 t.Fatalf("reload after remove loaded=%d; want 0", loaded)
727 }991 }
728 }992 }
729 993
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+
730 // TestActiveSessionScopesSnapshotAll proves the session filter the1025 // TestActiveSessionScopesSnapshotAll proves the session filter the
731-// user asked for: a single Swarm with agents spawned under two1026+// user asked for: a single Supervisor with agents spawned under two
732 // different active sessions only surfaces the agents matching the1027 // different active sessions only surfaces the agents matching the
733 // currently-active session via SnapshotAll. Switching the active1028 // currently-active session via SnapshotAll. Switching the active
734 // session re-narrows the view without touching agent state.1029 // session re-narrows the view without touching agent state.
@@ -790,14 +1085,14 @@ func TestActiveSessionScopesSnapshotAll(t *testing.T) {
790 }1085 }
791 1086
792 // TestSessionIDPersistsAcrossReload confirms the SessionID field1087 // TestSessionIDPersistsAcrossReload confirms the SessionID field
793-// survives a Swarm restart: an agent spawned under session A in one1088+// survives a Supervisor restart: an agent spawned under session A in one
794-// Swarm instance is still SessionID="sess-A" after a fresh New +1089+// Supervisor instance is still SessionID="sess-A" after a fresh New +
795 // Reload reads it back from meta.json. Without persistence, the1090 // Reload reads it back from meta.json. Without persistence, the
796 // scope filter would forget which session owned each agent after1091 // scope filter would forget which session owned each agent after
797 // a zot restart and the dashboard would leak everything again.1092 // a zot restart and the dashboard would leak everything again.
798 func TestSessionIDPersistsAcrossReload(t *testing.T) {1093 func TestSessionIDPersistsAcrossReload(t *testing.T) {
799 root := t.TempDir()1094 root := t.TempDir()
800- mkSwarm := func() *Swarm {1095+ mkSupervisor := func() *Supervisor {
801 return New(Config{1096 return New(Config{
802 Root: root, RepoRoot: root,1097 Root: root, RepoRoot: root,
803 NewRunner: func(a *Agent) Runner {1098 NewRunner: func(a *Agent) Runner {
@@ -806,7 +1101,7 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) {
806 })1101 })
807 }1102 }
808 1103
809- f := mkSwarm()1104+ f := mkSupervisor()
810 f.SetActiveSession("sess-keep")1105 f.SetActiveSession("sess-keep")
811 a, err := f.Spawn(context.Background(), "persist me")1106 a, err := f.Spawn(context.Background(), "persist me")
812 if err != nil {1107 if err != nil {
@@ -815,8 +1110,8 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) {
815 _ = f.Stop(a.ID)1110 _ = f.Stop(a.ID)
816 a.Wait()1111 a.Wait()
817 1112
818- // Fresh Swarm, same root, reload from disk.1113+ // Fresh Supervisor, same root, reload from disk.
819- g := mkSwarm()1114+ g := mkSupervisor()
820 if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {1115 if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {
821 t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs)1116 t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs)
822 }1117 }
@@ -826,14 +1121,15 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) {
826 t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID)1121 t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID)
827 }1122 }
828 1123
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.
830 g.SetActiveSession("sess-other")1126 g.SetActiveSession("sess-other")
831- if got := snapshotIDs(g.SnapshotAll()); len(got) != 0 {1127+ if got := snapshotIDs(g.SnapshotAll()); len(got) != 1 || got[0] != a.ID {
832- t.Errorf("scoped to other session, ids = %v; want []", got)1128+ t.Errorf("scoped to other session, ids = %v; want [%s]", got, a.ID)
833 }1129 }
834 }1130 }
835 1131
836-// TestEmptySessionIDIsVisibleFromAnyScope pins the backward-compat1132+// TestEmptySessionIDIsVisibleFromAnyScope pins the unscoped-agent
837 // rule: agents whose meta.json predates the SessionID field (or who1133 // rule: agents whose meta.json predates the SessionID field (or who
838 // were spawned without an active session, e.g. via a test rig or1134 // were spawned without an active session, e.g. via a test rig or
839 // scripted caller) carry SessionID == "" and remain visible from1135 // scripted caller) carry SessionID == "" and remain visible from
@@ -848,7 +1144,7 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) {
848 },1144 },
849 })1145 })
850 // No SetActiveSession call → agent spawned with empty SessionID.1146 // No SetActiveSession call → agent spawned with empty SessionID.
851- a, err := f.Spawn(context.Background(), "legacy")1147+ a, err := f.Spawn(context.Background(), "unscoped")
852 if err != nil {1148 if err != nil {
853 t.Fatal(err)1149 t.Fatal(err)
854 }1150 }
@@ -860,7 +1156,7 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) {
860 f.SetActiveSession(scope)1156 f.SetActiveSession(scope)
861 got := snapshotIDs(f.SnapshotAll())1157 got := snapshotIDs(f.SnapshotAll())
862 if len(got) != 1 || got[0] != a.ID {1158 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)
864 }1160 }
865 }1161 }
866 1162
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 @@
1 // Package subagents discovers named agent profiles that can be selected by1 // 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:
3 // a YAML-like metadata block followed by the agent's system prompt.3 // a YAML-like metadata block followed by the agent's system prompt.
4 //4 //
5 // Discovery prefers explicitly configured directories, then the shared5 // Discovery prefers explicitly configured directories, then the shared
@@ -132,7 +132,7 @@ func SystemPromptAddendum(profiles []*Profile) string {
132 }132 }
133 var sb strings.Builder133 var sb strings.Builder
134 sb.WriteString("[subagents_list]\n")134 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")
136 for _, profile := range profiles {136 for _, profile := range profiles {
137 if profile == nil {137 if profile == nil {
138 continue138 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 swarm1+package subagents
2 2
3 import (3 import (
4 "context"4 "context"
@@ -15,11 +15,11 @@ import (
15 // TestRunnerEndToEndWithStubChild is the integration test for the15 // TestRunnerEndToEndWithStubChild is the integration test for the
16 // new daemon-mode runner. It compiles the stubchild binary in16 // new daemon-mode runner. It compiles the stubchild binary in
17 // testdata/cmd/stubchild, points an execRunner at it, and drives17 // 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.
19 //19 //
20 // What this proves:20 // What this proves:
21 //21 //
22-// - The default argv shape (swarmAgentArgs) is one the child can22+// - The default argv shape (subagentWorkerArgs) is one the child can
23 // actually parse — locks the shape against silent breakage.23 // actually parse — locks the shape against silent breakage.
24 // - The stdout JSONL parser ingests events and writes them to the24 // - The stdout JSONL parser ingests events and writes them to the
25 // durable log.25 // durable log.
@@ -40,6 +40,7 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) {
40 }40 }
41 41
42 exe := buildStubChild(t)42 exe := buildStubChild(t)
43+ t.Setenv("ZOT_STUB_PROTOCOL", "1")
43 44
44 root := t.TempDir()45 root := t.TempDir()
45 repo := t.TempDir()46 repo := t.TempDir()
@@ -49,7 +50,7 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) {
49 NewRunner: func(a *Agent) Runner {50 NewRunner: func(a *Agent) Runner {
50 return &execRunner{51 return &execRunner{
51 agent: a,52 agent: a,
52- Command: swarmAgentArgs(swarmAgentArgsOpts{53+ Command: subagentWorkerArgs(subagentWorkerArgsOpts{
53 Exe: exe,54 Exe: exe,
54 Dir: a.Dir,55 Dir: a.Dir,
55 SessionPath: a.SessionPath,56 SessionPath: a.SessionPath,
@@ -93,13 +94,13 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) {
93 94
94 // Send a follow-up over the inbox. The stub echoes the text into95 // Send a follow-up over the inbox. The stub echoes the text into
95 // an assistant_message we can poll for in the log.96 // 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+ if err := retrySend(f, a.ID, "follow up", time.Second); err != nil {
97- t.Fatalf("SendInput: %v", err)98+ t.Fatalf("SendUserTurn: %v", err)
98 }99 }
99 waitFor("echo: follow up")100 waitFor("echo: follow up")
100 101
101 // Shut the agent down gracefully via the inbox.102 // 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) {
103 t.Fatalf("shutdown send: %v", err)104 t.Fatalf("shutdown send: %v", err)
104 }105 }
105 a.Wait()106 a.Wait()
@@ -111,11 +112,11 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) {
111 // retrySend exists because the inbox dial races against the child112 // retrySend exists because the inbox dial races against the child
112 // opening the socket. Production callers handle ErrNotReady with a113 // opening the socket. Production callers handle ErrNotReady with a
113 // status message; tests retry within a small window.114 // 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 {
115 deadline := time.Now().Add(timeout)116 deadline := time.Now().Add(timeout)
116 var lastErr error117 var lastErr error
117 for time.Now().Before(deadline) {118 for time.Now().Before(deadline) {
118- err := f.SendInput(id, msg)119+ err := f.SendUserTurn(id, msg)
119 if err == nil {120 if err == nil {
120 return nil121 return nil
121 }122 }
R packages/agent/swarm/runner_test.go → packages/agent/subagents/runner_test.go +151 -16 renamed, 58% similar
@@ -1,4 +1,4 @@
1-package swarm1+package subagents
2 2
3 import (3 import (
4 "context"4 "context"
@@ -11,8 +11,8 @@ import (
11 "time"11 "time"
12 )12 )
13 13
14-// TestSwarmAgentArgs locks in the exact flag set the subprocess14+// TestSubagentWorkerArgs locks in the exact flag set the subprocess
15-// runner uses to start a swarm agent in daemon mode. Past15+// runner uses to start a subagent agent in daemon mode. Past
16 // regressions in this area:16 // regressions in this area:
17 //17 //
18 // - "--no-sess" instead of "--no-session" (old print-mode runner):18 // - "--no-sess" instead of "--no-session" (old print-mode runner):
@@ -31,10 +31,10 @@ import (
31 // positions. If a flag is renamed, update both the runner and this31 // positions. If a flag is renamed, update both the runner and this
32 // test so we notice immediately.32 // test so we notice immediately.
33 func TestCredentialStdinHelperProcess(t *testing.T) {33 func TestCredentialStdinHelperProcess(t *testing.T) {
34- if os.Getenv("ZOT_SWARM_CREDENTIAL_HELPER") != "1" {34+ if os.Getenv("ZOT_SUBAGENT_CREDENTIAL_HELPER") != "1" {
35 return35 return
36 }36 }
37- if os.Getenv("ZOT_SWARM_CREDENTIAL_STDIN") != "1" {37+ if os.Getenv("ZOT_SUBAGENT_CREDENTIAL_STDIN") != "1" {
38 os.Exit(2)38 os.Exit(2)
39 }39 }
40 var credential Credential40 var credential Credential
@@ -58,8 +58,47 @@ func TestCredentialStdinHelperProcess(t *testing.T) {
58 os.Exit(0)58 os.Exit(0)
59 }59 }
60 60
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+
61 func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {100 func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {
62- t.Setenv("ZOT_SWARM_CREDENTIAL_HELPER", "1")101+ t.Setenv("ZOT_SUBAGENT_CREDENTIAL_HELPER", "1")
63 root := t.TempDir()102 root := t.TempDir()
64 a := &Agent{103 a := &Agent{
65 ID: "credential-test",104 ID: "credential-test",
@@ -106,8 +145,8 @@ func TestApplyEventPreservesMultilineRolesAndFinalAssistant(t *testing.T) {
106 }145 }
107 }146 }
108 147
109-func TestSwarmAgentArgs(t *testing.T) {148+func TestSubagentWorkerArgs(t *testing.T) {
110- args := swarmAgentArgs(swarmAgentArgsOpts{149+ args := subagentWorkerArgs(subagentWorkerArgsOpts{
111 Exe: "/path/to/zot",150 Exe: "/path/to/zot",
112 Dir: "/tmp/worktree",151 Dir: "/tmp/worktree",
113 SessionPath: "/tmp/state/session.json",152 SessionPath: "/tmp/state/session.json",
@@ -129,11 +168,11 @@ func TestSwarmAgentArgs(t *testing.T) {
129 }168 }
130 169
131 mustHave := map[string]string{170 mustHave := map[string]string{
132- "--swarm-agent": "/tmp/state/in.sock",171+ "--subagent-worker": "/tmp/state/in.sock",
133- "--session": "/tmp/state/session.json",172+ "--session": "/tmp/state/session.json",
134- "--cwd": "/tmp/worktree",173+ "--cwd": "/tmp/worktree",
135- "--reasoning": "high",174+ "--reasoning": "high",
136- "--subagent": "reviewer",175+ "--subagent": "reviewer",
137 }176 }
138 for flag, value := range mustHave {177 for flag, value := range mustHave {
139 i := indexOf(args, flag)178 i := indexOf(args, flag)
@@ -155,11 +194,48 @@ func TestSwarmAgentArgs(t *testing.T) {
155 }194 }
156 }195 }
157 196
158-// TestSwarmAgentArgsEmptyTaskOmitsPositional makes sure that when the197+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
159 // agent is being adopted (no fresh task) we don't pass an empty235 // agent is being adopted (no fresh task) we don't pass an empty
160 // positional which the arg parser would treat as a real prompt.236 // positional which the arg parser would treat as a real prompt.
161-func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) {237+func TestSubagentWorkerArgsEmptyTaskOmitsPositional(t *testing.T) {
162- args := swarmAgentArgs(swarmAgentArgsOpts{238+ args := subagentWorkerArgs(subagentWorkerArgsOpts{
163 Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",239 Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",
164 })240 })
165 for _, a := range args {241 for _, a := range args {
@@ -212,6 +288,40 @@ func TestDefaultChildArgsResumeOmitsTask(t *testing.T) {
212 }288 }
213 }289 }
214 290
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+
215 func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) {325 func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) {
216 called := make(chan struct{}, 1)326 called := make(chan struct{}, 1)
217 a := &Agent{}327 a := &Agent{}
@@ -251,6 +361,31 @@ func TestNotifyPromptTurnEndFiresForDaemonPromptCompletion(t *testing.T) {
251 }361 }
252 }362 }
253 363
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+
254 func indexOf(xs []string, want string) int {389 func indexOf(xs []string, want string) int {
255 for i, x := range xs {390 for i, x := range xs {
256 if x == want {391 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 swarm1+package subagents
2 2
3 import (3 import (
4 "crypto/sha1"4 "crypto/sha1"
@@ -18,7 +18,7 @@ import (
18 const maxUnixSocketPath = 10018 const maxUnixSocketPath = 100
19 19
20 // inboxSocketPath returns a per-agent unix-socket path in a transient20 // inboxSocketPath returns a per-agent unix-socket path in a transient
21-// runtime directory. Durable swarm state can live on filesystems such as21+// runtime directory. Durable subagent state can live on filesystems such as
22 // NFS, 9p, or shared VM mounts that support regular files but reject unix22 // NFS, 9p, or shared VM mounts that support regular files but reject unix
23 // socket nodes, so the socket must not be placed below root.23 // socket nodes, so the socket must not be placed below root.
24 //24 //
@@ -33,7 +33,7 @@ const maxUnixSocketPath = 100
33 // stable path across parent and child processes and across Resume calls.33 // stable path across parent and child processes and across Resume calls.
34 func inboxSocketPath(root, agentID string) (string, error) {34 func inboxSocketPath(root, agentID string) (string, error) {
35 if runtime.GOOS == "windows" {35 if runtime.GOOS == "windows" {
36- // Keep swarm construction usable for callers with custom runners.36+ // Keep subagent construction usable for callers with custom runners.
37 // The production listener still reports that its unix transport is37 // The production listener still reports that its unix transport is
38 // unavailable, as it did before runtime-directory probing was added.38 // unavailable, as it did before runtime-directory probing was added.
39 return filepath.Join(root, "agents", agentID, "in.sock"), nil39 return filepath.Join(root, "agents", agentID, "in.sock"), nil
@@ -65,7 +65,7 @@ func inboxSocketPath(root, agentID string) (string, error) {
65 }65 }
66 66
67 func socketPathInBase(base, root, agentID string) (string, error) {67 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))
69 if err := os.MkdirAll(dir, 0o700); err != nil {69 if err := os.MkdirAll(dir, 0o700); err != nil {
70 return "", fmt.Errorf("socket dir %s: %w", dir, err)70 return "", fmt.Errorf("socket dir %s: %w", dir, err)
71 }71 }
@@ -109,7 +109,7 @@ func probeUnixSocket(dir string) error {
109 return errors.Join(closeErr, removeErr)109 return errors.Join(closeErr, removeErr)
110 }110 }
111 111
112-// rootTag returns a stable 8-hex-char tag for the swarm root. Used112+// rootTag returns a stable 8-hex-char tag for the subagent root. Used
113 // in the runtime-directory name so two parallel zot instances with113 // in the runtime-directory name so two parallel zot instances with
114 // different roots don't share sockets.114 // different roots don't share sockets.
115 func rootTag(root string) string { return shortHash(root) }115 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 swarm1+package subagents
2 2
3 import (3 import (
4 "os"4 "os"
@@ -10,7 +10,7 @@ import (
10 10
11 func TestInboxSocketPathUsesRuntimeDirectory(t *testing.T) {11 func TestInboxSocketPathUsesRuntimeDirectory(t *testing.T) {
12 if runtime.GOOS == "windows" {12 if runtime.GOOS == "windows" {
13- t.Skip("swarm inbox transport uses Unix-domain sockets")13+ t.Skip("subagent inbox transport uses Unix-domain sockets")
14 }14 }
15 15
16 runtimeDir := shortSocketDir(t)16 runtimeDir := shortSocketDir(t)
@@ -31,7 +31,7 @@ func TestInboxSocketPathUsesRuntimeDirectory(t *testing.T) {
31 31
32 func TestInboxSocketPathFallsBackFromUnusableRuntimeDirectory(t *testing.T) {32 func TestInboxSocketPathFallsBackFromUnusableRuntimeDirectory(t *testing.T) {
33 if runtime.GOOS == "windows" {33 if runtime.GOOS == "windows" {
34- t.Skip("swarm inbox transport uses Unix-domain sockets")34+ t.Skip("subagent inbox transport uses Unix-domain sockets")
35 }35 }
36 36
37 badRuntime := filepath.Join(shortSocketDir(t), "not-a-directory")37 badRuntime := filepath.Join(shortSocketDir(t), "not-a-directory")
@@ -53,7 +53,7 @@ func TestInboxSocketPathFallsBackFromUnusableRuntimeDirectory(t *testing.T) {
53 53
54 func TestInboxSocketPathShortensLongAgentID(t *testing.T) {54 func TestInboxSocketPathShortensLongAgentID(t *testing.T) {
55 if runtime.GOOS == "windows" {55 if runtime.GOOS == "windows" {
56- t.Skip("swarm inbox transport uses Unix-domain sockets")56+ t.Skip("subagent inbox transport uses Unix-domain sockets")
57 }57 }
58 58
59 t.Setenv("XDG_RUNTIME_DIR", shortSocketDir(t))59 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 swarm1+package subagents
2 2
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "path/filepath"
7+ "runtime"
6 "strings"8 "strings"
7 "sync"9 "sync"
8 "testing"10 "testing"
9 "time"11 "time"
10 )12 )
11 13
12-// newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory14+// newTestSupervisor builds a Supervisor rooted in t.TempDir and configured
13-// and a Runner factory the test controls. Returns the swarm plus a15+// with the Runner factory controlled by the test.
14-// slice of runners keyed by spawn order so tests can assert they16+func newTestSupervisor(t *testing.T, mk func(a *Agent) Runner) *Supervisor {
15-// were actually invoked.
16-func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm {
17 t.Helper()17 t.Helper()
18 root := t.TempDir()18 root := t.TempDir()
19 return New(Config{19 return New(Config{
@@ -25,7 +25,7 @@ func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm {
25 25
26 func TestSpawnRunsAndCompletes(t *testing.T) {26 func TestSpawnRunsAndCompletes(t *testing.T) {
27 ran := make(chan string, 1)27 ran := make(chan string, 1)
28- f := newTestSwarm(t, func(a *Agent) Runner {28+ f := newTestSupervisor(t, func(a *Agent) Runner {
29 return RunnerFunc(func(ctx context.Context, sink Sink) error {29 return RunnerFunc(func(ctx context.Context, sink Sink) error {
30 ran <- a.Task30 ran <- a.Task
31 sink.Activity("hello")31 sink.Activity("hello")
@@ -65,7 +65,7 @@ func TestSpawnRunsAndCompletes(t *testing.T) {
65 // TestSpawnAgentSharesRepoRoot verifies the only-mode-we-support:65 // TestSpawnAgentSharesRepoRoot verifies the only-mode-we-support:
66 // every spawned agent points its cwd at the parent zot's RepoRoot.66 // every spawned agent points its cwd at the parent zot's RepoRoot.
67 func TestSpawnAgentSharesRepoRoot(t *testing.T) {67 func TestSpawnAgentSharesRepoRoot(t *testing.T) {
68- f := newTestSwarm(t, func(a *Agent) Runner {68+ f := newTestSupervisor(t, func(a *Agent) Runner {
69 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })69 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
70 })70 })
71 a, err := f.Spawn(context.Background(), "share me")71 a, err := f.Spawn(context.Background(), "share me")
@@ -79,7 +79,7 @@ func TestSpawnAgentSharesRepoRoot(t *testing.T) {
79 }79 }
80 80
81 func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) {81 func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) {
82- f := newTestSwarm(t, func(a *Agent) Runner {82+ f := newTestSupervisor(t, func(a *Agent) Runner {
83 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })83 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
84 })84 })
85 want := t.TempDir()85 want := t.TempDir()
@@ -95,7 +95,7 @@ func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) {
95 }95 }
96 96
97 func TestSpawnEmptyTaskFails(t *testing.T) {97 func TestSpawnEmptyTaskFails(t *testing.T) {
98- f := newTestSwarm(t, func(a *Agent) Runner {98+ f := newTestSupervisor(t, func(a *Agent) Runner {
99 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })99 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
100 })100 })
101 if _, err := f.Spawn(context.Background(), " "); err == nil {101 if _, err := f.Spawn(context.Background(), " "); err == nil {
@@ -105,7 +105,7 @@ func TestSpawnEmptyTaskFails(t *testing.T) {
105 105
106 func TestRunnerErrorMarksFailed(t *testing.T) {106 func TestRunnerErrorMarksFailed(t *testing.T) {
107 wantErr := errors.New("boom")107 wantErr := errors.New("boom")
108- f := newTestSwarm(t, func(a *Agent) Runner {108+ f := newTestSupervisor(t, func(a *Agent) Runner {
109 return RunnerFunc(func(ctx context.Context, sink Sink) error { return wantErr })109 return RunnerFunc(func(ctx context.Context, sink Sink) error { return wantErr })
110 })110 })
111 a, _ := f.Spawn(context.Background(), "explode")111 a, _ := f.Spawn(context.Background(), "explode")
@@ -123,7 +123,7 @@ func TestRunnerErrorMarksFailed(t *testing.T) {
123 123
124 func TestStopCancelsRunningAgent(t *testing.T) {124 func TestStopCancelsRunningAgent(t *testing.T) {
125 started := make(chan struct{})125 started := make(chan struct{})
126- f := newTestSwarm(t, func(a *Agent) Runner {126+ f := newTestSupervisor(t, func(a *Agent) Runner {
127 return RunnerFunc(func(ctx context.Context, sink Sink) error {127 return RunnerFunc(func(ctx context.Context, sink Sink) error {
128 close(started)128 close(started)
129 <-ctx.Done()129 <-ctx.Done()
@@ -141,8 +141,133 @@ func TestStopCancelsRunningAgent(t *testing.T) {
141 }141 }
142 }142 }
143 143
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+
144 func TestStopAfterDoneIsNoop(t *testing.T) {269 func TestStopAfterDoneIsNoop(t *testing.T) {
145- f := newTestSwarm(t, func(a *Agent) Runner {270+ f := newTestSupervisor(t, func(a *Agent) Runner {
146 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })271 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
147 })272 })
148 a, _ := f.Spawn(context.Background(), "quick")273 a, _ := f.Spawn(context.Background(), "quick")
@@ -156,7 +281,7 @@ func TestStopAfterDoneIsNoop(t *testing.T) {
156 }281 }
157 282
158 func TestGetPrefixMatch(t *testing.T) {283 func TestGetPrefixMatch(t *testing.T) {
159- f := newTestSwarm(t, func(a *Agent) Runner {284+ f := newTestSupervisor(t, func(a *Agent) Runner {
160 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })285 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
161 })286 })
162 a, _ := f.Spawn(context.Background(), "alpha task")287 a, _ := f.Spawn(context.Background(), "alpha task")
@@ -177,7 +302,7 @@ func TestGetPrefixMatch(t *testing.T) {
177 302
178 func TestRemoveRequiresTerminalState(t *testing.T) {303 func TestRemoveRequiresTerminalState(t *testing.T) {
179 hold := make(chan struct{})304 hold := make(chan struct{})
180- f := newTestSwarm(t, func(a *Agent) Runner {305+ f := newTestSupervisor(t, func(a *Agent) Runner {
181 return RunnerFunc(func(ctx context.Context, sink Sink) error {306 return RunnerFunc(func(ctx context.Context, sink Sink) error {
182 <-hold307 <-hold
183 return nil308 return nil
@@ -204,7 +329,7 @@ func TestRemoveRequiresTerminalState(t *testing.T) {
204 func TestSnapshotIsStableAcrossAccess(t *testing.T) {329 func TestSnapshotIsStableAcrossAccess(t *testing.T) {
205 var wg sync.WaitGroup330 var wg sync.WaitGroup
206 wg.Add(1)331 wg.Add(1)
207- f := newTestSwarm(t, func(a *Agent) Runner {332+ f := newTestSupervisor(t, func(a *Agent) Runner {
208 return RunnerFunc(func(ctx context.Context, sink Sink) error {333 return RunnerFunc(func(ctx context.Context, sink Sink) error {
209 defer wg.Done()334 defer wg.Done()
210 for i := 0; i < 200; i++ {335 for i := 0; i < 200; i++ {
@@ -236,6 +361,19 @@ func TestSnapshotIsStableAcrossAccess(t *testing.T) {
236 }361 }
237 }362 }
238 363
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+
239 func TestTaskSlug(t *testing.T) {377 func TestTaskSlug(t *testing.T) {
240 cases := map[string]string{378 cases := map[string]string{
241 "fix the login form": "fix-the-login-form",379 "fix the login form": "fix-the-login-form",
@@ -251,7 +389,7 @@ func TestTaskSlug(t *testing.T) {
251 }389 }
252 390
253 func TestSnapshotAllSorted(t *testing.T) {391 func TestSnapshotAllSorted(t *testing.T) {
254- f := newTestSwarm(t, func(a *Agent) Runner {392+ f := newTestSupervisor(t, func(a *Agent) Runner {
255 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })393 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
256 })394 })
257 a1, _ := f.Spawn(context.Background(), "first")395 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 runner1+// stubchild is a minimal subagent-worker stand-in used by the runner
2 // end-to-end test. It speaks the daemon protocol the real zot2 // end-to-end test. It speaks the daemon protocol the real zot
3 // binary will implement next:3 // binary will implement next:
4 //4 //
5-// - parses --swarm-agent <path>, --session <path>, --cwd <path>,5+// - parses --subagent-worker <path>, --session <path>, --cwd <path>,
6 // and an optional positional task,6 // and an optional positional task,
7 // - opens a unix-socket listener at the inbox path so the7 // - opens a unix-socket listener at the inbox path so the
8 // supervisor's Inbox can dial through,8 // supervisor's Inbox can dial through,
@@ -13,7 +13,7 @@
13 // so the runner sees the dialogue happen.13 // so the runner sees the dialogue happen.
14 //14 //
15 // The runner test compiles this binary into a tempdir, points15 // The runner test compiles this binary into a tempdir, points
16-// swarm's execRunner at it via Command, and asserts the events16+// subagent's execRunner at it via Command, and asserts the events
17 // flow through correctly without needing the full zot model17 // flow through correctly without needing the full zot model
18 // machinery.18 // machinery.
19 package main19 package main
@@ -27,6 +27,8 @@ import (
27 "os"27 "os"
28 "sync"28 "sync"
29 "time"29 "time"
30+
31+ "github.com/patriceckhart/zot/packages/agent/subagents"
30 )32 )
31 33
32 func main() {34 func main() {
@@ -35,13 +37,13 @@ func main() {
35 sessionPath string37 sessionPath string
36 cwd string38 cwd string
37 )39 )
38- flag.StringVar(&inboxPath, "swarm-agent", "", "inbox socket path")40+ flag.StringVar(&inboxPath, "subagent-worker", "", "inbox socket path")
39 flag.StringVar(&sessionPath, "session", "", "session file path")41 flag.StringVar(&sessionPath, "session", "", "session file path")
40 flag.StringVar(&cwd, "cwd", "", "working directory")42 flag.StringVar(&cwd, "cwd", "", "working directory")
41 flag.Parse()43 flag.Parse()
42 44
43 if inboxPath == "" {45 if inboxPath == "" {
44- fmt.Fprintln(os.Stderr, "stubchild: --swarm-agent required")46+ fmt.Fprintln(os.Stderr, "stubchild: --subagent-worker required")
45 os.Exit(2)47 os.Exit(2)
46 }48 }
47 49
@@ -49,7 +51,7 @@ func main() {
49 51
50 // Open the inbox listener BEFORE doing any work so the supervisor52 // Open the inbox listener BEFORE doing any work so the supervisor
51 // can dial through as soon as it sees the agent_ready event. If we53 // can dial through as soon as it sees the agent_ready event. If we
52- // processed the initial task first, the parent's SendInput call54+ // processed the initial task first, the parent's send call
53 // would race the stub's net.Listen and trip ErrNotReady.55 // would race the stub's net.Listen and trip ErrNotReady.
54 ln, err := net.Listen("unix", inboxPath)56 ln, err := net.Listen("unix", inboxPath)
55 if err != nil {57 if err != nil {
@@ -81,18 +83,25 @@ func main() {
81 line, err := br.ReadString('\n')83 line, err := br.ReadString('\n')
82 if line != "" {84 if line != "" {
83 msg := trimNL(line)85 msg := trimNL(line)
84- switch {86+ command, parseErr := subagents.ParseCommand(msg)
85- case msg == "shutdown":87+ if parseErr == nil {
86- emit("agent_stopped", map[string]any{"reason": "shutdown"})88+ switch command.Type {
87- _ = c.Close()89+ case subagents.CommandAgentShutdown:
88- return90+ emit("agent_stopped", map[string]any{"reason": "shutdown"})
89- case msg == "cancel":91+ _ = c.Close()
90- emit("turn_end", map[string]any{"stop": "cancelled"})92+ return
91- case len(msg) > 5 && msg[:5] == "user ":93+ case subagents.CommandTurnCancel:
92- runTurn(emit, msg[5:], turn)94+ emit("turn_end", map[string]any{"stop": "cancelled"})
93- turn++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+ }
94 }102 }
95 }103 }
104+
96 if err != nil {105 if err != nil {
97 _ = c.Close()106 _ = c.Close()
98 break107 break
@@ -125,12 +134,18 @@ type emitter = func(string, map[string]any)
125 func newEmitter() emitter {134 func newEmitter() emitter {
126 var mu sync.Mutex135 var mu sync.Mutex
127 enc := json.NewEncoder(os.Stdout)136 enc := json.NewEncoder(os.Stdout)
137+ versioned := os.Getenv("ZOT_STUB_PROTOCOL") == "1"
128 return func(typ string, data map[string]any) {138 return func(typ string, data map[string]any) {
129 mu.Lock()139 mu.Lock()
130 defer mu.Unlock()140 defer mu.Unlock()
131 if data == nil {141 if data == nil {
132 data = map[string]any{}142 data = map[string]any{}
133 }143 }
144+ if versioned {
145+ envelope := subagents.NewEventEnvelope(typ, "stub-agent", "", data)
146+ _ = enc.Encode(envelope)
147+ return
148+ }
134 data["type"] = typ149 data["type"] = typ
135 data["time"] = time.Now().Format(time.RFC3339Nano)150 data["time"] = time.Now().Format(time.RFC3339Nano)
136 _ = enc.Encode(data)151 _ = 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 (
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7 "strings"7 "strings"
8+ "time"
8 9
9 "github.com/patriceckhart/zot/packages/agent/subagents"10 "github.com/patriceckhart/zot/packages/agent/subagents"
10- "github.com/patriceckhart/zot/packages/agent/swarm"
11 "github.com/patriceckhart/zot/packages/core"11 "github.com/patriceckhart/zot/packages/core"
12 "github.com/patriceckhart/zot/packages/provider"12 "github.com/patriceckhart/zot/packages/provider"
13 )13 )
14 14
15-// SwarmSpawnTool lets the main agent fork a background sub-agent15+// SubagentSpawnTool lets the main agent fork a background sub-agent
16-// against the host's cwd via swarm.Swarm.SpawnReq. The sub-agent runs16+// against the host's cwd via subagents.Supervisor.SpawnReq. The sub-agent runs
17 // in parallel: the tool returns the agent id immediately and the main17 // in parallel: the tool returns the agent id immediately and the main
18 // turn continues uninterrupted. The user can monitor / chat with the18 // turn continues uninterrupted. The user can monitor / chat with the
19-// spawned agent via /swarm.19+// spawned agent via /subagents.
20 //20 //
21-// Gated by the auto_swarm_enabled config flag at call time so a user21+// Gated by the auto_subagents_enabled config flag at call time so a user
22 // can flip it off mid-session and the next call refuses cleanly22 // can flip it off mid-session and the next call refuses cleanly
23 // without re-registering the tool.23 // without re-registering the tool.
24-type SwarmSpawnTool struct {24+type SubagentSpawnTool struct {
25- // Swarm is the supervisor used to spawn agents. Nil means25+ // Supervisor is the supervisor used to spawn agents. Nil means
26- // "auto-swarm not available in this mode" and the tool always26+ // "auto-subagents not available in this mode" and the tool always
27 // errors.27 // errors.
28- Swarm *swarm.Swarm28+ Supervisor *subagents.Supervisor
29 29
30 // Enabled reads the live config flag. Lets users toggle from30 // Enabled reads the live config flag. Lets users toggle from
31 // /settings without rebuilding the agent. When nil, the tool31 // /settings without rebuilding the agent. When nil, the tool
@@ -34,7 +34,7 @@ type SwarmSpawnTool struct {
34 34
35 // DefaultModel and DefaultProvider return the host agent's resolved35 // DefaultModel and DefaultProvider return the host agent's resolved
36 // model and provider. They are used when the tool call omits both36 // model and provider. They are used when the tool call omits both
37- // fields and does not select a named profile, so auto-swarm follows37+ // fields and does not select a named profile, so auto-subagents follows
38 // the same auth route as the user sees in the parent session.38 // the same auth route as the user sees in the parent session.
39 DefaultModel func() string39 DefaultModel func() string
40 DefaultProvider func() string40 DefaultProvider func() string
@@ -48,19 +48,22 @@ type SwarmSpawnTool struct {
48 // the new agent + the task it was started with. Used by the48 // the new agent + the task it was started with. Used by the
49 // interactive host to track agents and surface a summary back49 // interactive host to track agents and surface a summary back
50 // in chat when all sub-agents finish.50 // in chat when all sub-agents finish.
51- OnSpawned func(agent *swarm.Agent, task string)51+ OnSpawned func(agent *subagents.Agent, task string)
52 }52 }
53 53
54-type swarmSpawnArgs struct {54+type subagentSpawnArgs struct {
55 Task string `json:"task"`55 Task string `json:"task"`
56 Agent string `json:"agent,omitempty"`56 Agent string `json:"agent,omitempty"`
57 Model string `json:"model,omitempty"`57 Model string `json:"model,omitempty"`
58 Provider string `json:"provider,omitempty"`58 Provider string `json:"provider,omitempty"`
59 Reasoning string `json:"reasoning,omitempty"`59 Reasoning string `json:"reasoning,omitempty"`
60 Thinking string `json:"thinking,omitempty"`60 Thinking string `json:"thinking,omitempty"`
61+ Isolation string `json:"isolation,omitempty"`
62+ Timeout string `json:"timeout,omitempty"`
63+ MaxTurns *int `json:"max_turns,omitempty"`
61 }64 }
62 65
63-const swarmSpawnSchema = `{66+const subagentSpawnSchema = `{
64 "type": "object",67 "type": "object",
65 "properties": {68 "properties": {
66 "task": {69 "task": {
@@ -88,31 +91,66 @@ const swarmSpawnSchema = `{
88 "type": "string",91 "type": "string",
89 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],92 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
90 "description": "Alias for reasoning, accepted for compatibility with common agent profile terminology. Prefer reasoning when both are available."93 "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."
91 }108 }
92 },109 },
93 "required": ["task"]110 "required": ["task"]
94 }`111 }`
95 112
96-func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" }113+func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" }
97-func (t *SwarmSpawnTool) Description() string {114+func (t *SubagentSpawnTool) 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."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+
99 }117 }
100-func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) }118+func (t *SubagentSpawnTool) Schema() json.RawMessage { return json.RawMessage(subagentSpawnSchema) }
101 119
102-func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {120+func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
103- if t.Swarm == nil {121+ prefix := t.Name()
104- return protocolToolError("swarm_spawn: swarm supervisor not available in this mode")122+ if t.Supervisor == nil {
123+ return protocolToolError(prefix + ": subagent supervisor not available in this mode")
105 }124 }
106 if t.Enabled == nil || !t.Enabled() {125 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.")
108 }127 }
109- var a swarmSpawnArgs128+ var a subagentSpawnArgs
110 if err := json.Unmarshal(raw, &a); err != nil {129 if err := json.Unmarshal(raw, &a); err != nil {
111 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)130 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
112 }131 }
113 task := strings.TrimSpace(a.Task)132 task := strings.TrimSpace(a.Task)
114 if task == "" {133 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")
116 }154 }
117 155
118 agentName := strings.TrimSpace(a.Agent)156 agentName := strings.TrimSpace(a.Agent)
@@ -120,15 +158,15 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
120 var fastModeOverride *bool158 var fastModeOverride *bool
121 if agentName != "" {159 if agentName != "" {
122 if t.ResolveSubagent == nil {160 if t.ResolveSubagent == nil {
123- return protocolToolError("swarm_spawn: named subagent profiles are unavailable")161+ return protocolToolError(prefix + ": named subagent profiles are unavailable")
124 }162 }
125 var err error163 var err error
126 profile, err = t.ResolveSubagent(agentName)164 profile, err = t.ResolveSubagent(agentName)
127 if err != nil {165 if err != nil {
128- return protocolToolError("swarm_spawn: " + err.Error())166+ return protocolToolError(prefix + ": " + err.Error())
129 }167 }
130 if profile == nil {168 if profile == nil {
131- return protocolToolError("swarm_spawn: unknown subagent profile " + agentName)169+ return protocolToolError(prefix + ": unknown subagent profile " + agentName)
132 }170 }
133 agentName = profile.Name171 agentName = profile.Name
134 fastModeOverride = profile.FastMode172 fastModeOverride = profile.FastMode
@@ -136,55 +174,67 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
136 174
137 model := strings.TrimSpace(a.Model)175 model := strings.TrimSpace(a.Model)
138 providerID := strings.TrimSpace(a.Provider)176 providerID := strings.TrimSpace(a.Provider)
177+ var profileTools []string
178+ if profile != nil {
179+ profileTools = append([]string(nil), profile.Tools...)
180+ }
139 if (model == "") != (providerID == "") {181 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")
141 }183 }
142- if model == "" && providerID == "" && profile != nil {184+ if profile != nil {
143- // A fully-qualified profile model is useful metadata for the185+ // A profile may specify a qualified model, a bare model, a provider,
144- // supervisor and also lets it inherit the right credential route.186+ // or neither. Fill only missing pieces so explicit spawn options win;
145- // Bare profile model ids remain child-resolved so the configured187+ // the parent session supplies whatever the profile leaves unspecified.
146- // provider is preserved.
147 profileProvider, profileModel := profile.ModelSelection()188 profileProvider, profileModel := profile.ModelSelection()
148- if profileProvider != "" && profileModel != "" {189+ if model == "" {
149- providerID, model = profileProvider, profileModel190+ model = profileModel
150 }191 }
151- }192+ if providerID == "" {
152- if model == "" && providerID == "" && profile == nil {193+ providerID = profileProvider
153- if t.DefaultModel != nil {
154- model = strings.TrimSpace(t.DefaultModel())
155- }
156- if t.DefaultProvider != nil {
157- providerID = strings.TrimSpace(t.DefaultProvider())
158 }194 }
159 }195 }
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+ }
160 202
161 reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)203 reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)
162 if err != nil {204 if err != nil {
163- return protocolToolError("swarm_spawn: " + err.Error())205+ return protocolToolError(prefix + ": " + err.Error())
164 }206 }
165 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {207 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
166 reasoning, err = reasoningOverride(profile.Thinking, "")208 reasoning, err = reasoningOverride(profile.Thinking, "")
167 if err != nil {209 if err != nil {
168- return protocolToolError("swarm_spawn: profile " + profile.Name + ": " + err.Error())210+ return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
169 }211 }
170 }212 }
171 if reasoning == "" && t.DefaultReasoning != nil {213 if reasoning == "" && t.DefaultReasoning != nil {
172 reasoning, err = reasoningOverride(t.DefaultReasoning(), "")214 reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
173 if err != nil {215 if err != nil {
174- return protocolToolError("swarm_spawn: host " + err.Error())216+ return protocolToolError(prefix + ": host " + err.Error())
175 }217 }
176 }218 }
177 219
178- agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{220+ maxTurns := 0
179- Task: task,221+ if a.MaxTurns != nil {
180- Model: model,222+ maxTurns = *a.MaxTurns
181- Provider: providerID,223+ }
182- Reasoning: reasoning,224+ agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
183- FastMode: fastModeOverride,225+ Task: task,
184- Subagent: agentName,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,
185 })235 })
186 if err != nil {236 if err != nil {
187- return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)237+ return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
188 }238 }
189 if t.OnSpawned != nil {239 if t.OnSpawned != nil {
190 t.OnSpawned(agent, task)240 t.OnSpawned(agent, task)
@@ -192,6 +242,8 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
192 242
193 var sb strings.Builder243 var sb strings.Builder
194 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)244 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)
195 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))247 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
196 if agentName != "" {248 if agentName != "" {
197 fmt.Fprintf(&sb, "agent: %s\n", agentName)249 fmt.Fprintf(&sb, "agent: %s\n", agentName)
@@ -208,18 +260,25 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
208 if agent.FastMode {260 if agent.FastMode {
209 sb.WriteString("fast mode: enabled\n")261 sb.WriteString("fast mode: enabled\n")
210 }262 }
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. ")
212 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")264 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
213 return core.ToolResult{265 return core.ToolResult{
214 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},266 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
215 Details: map[string]any{267 Details: map[string]any{
216- "agent_id": agent.ID,268+ "agent_id": agent.ID,
217- "task": task,269+ "task": task,
218- "agent": agentName,270+ "agent": agentName,
219- "model": model,271+ "model": model,
220- "provider": providerID,272+ "provider": providerID,
221- "reasoning": reasoning,273+ "reasoning": reasoning,
222- "fast_mode": agent.FastMode,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),
223 },282 },
224 }, nil283 }, nil
225 }284 }
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 (
6 "path/filepath"6 "path/filepath"
7 "strings"7 "strings"
8 "testing"8 "testing"
9+ "time"
9 10
10 "github.com/patriceckhart/zot/packages/agent/subagents"11 "github.com/patriceckhart/zot/packages/agent/subagents"
11- "github.com/patriceckhart/zot/packages/agent/swarm"
12 "github.com/patriceckhart/zot/packages/provider"12 "github.com/patriceckhart/zot/packages/provider"
13 )13 )
14 14
15-type noopSwarmRunner struct{}15+type noopSupervisorRunner struct{}
16 16
17-func (noopSwarmRunner) Run(context.Context, swarm.Sink) error { return nil }17+func (noopSupervisorRunner) Run(context.Context, subagents.Sink) error { return nil }
18 18
19-func newTestSwarm(t *testing.T) *swarm.Swarm {19+func newTestSupervisor(t *testing.T) *subagents.Supervisor {
20 t.Helper()20 t.Helper()
21 root := t.TempDir()21 root := t.TempDir()
22- return swarm.New(swarm.Config{22+ manager := subagents.New(subagents.Config{
23- Root: filepath.Join(root, "swarm"),23+ Root: filepath.Join(root, "subagents"),
24 RepoRoot: root,24 RepoRoot: root,
25- NewRunner: func(*swarm.Agent) swarm.Runner {25+ NewRunner: func(*subagents.Agent) subagents.Runner {
26- return noopSwarmRunner{}26+ return noopSupervisorRunner{}
27 },27 },
28 })28 })
29+ t.Cleanup(manager.StopAll)
30+ return manager
29 }31 }
30 32
31-func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {33+func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
32- tool := &SwarmSpawnTool{34+ tool := &SubagentSpawnTool{
33- Swarm: newTestSwarm(t),35+ Supervisor: newTestSupervisor(t),
34 Enabled: func() bool { return true },36 Enabled: func() bool { return true },
35 DefaultModel: func() string { return "gpt-5" },37 DefaultModel: func() string { return "gpt-5" },
36 DefaultProvider: func() string { return "openai-codex" },38 DefaultProvider: func() string { return "openai-codex" },
@@ -62,19 +64,78 @@ func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
62 t.Fatalf("result text missing inherited model/provider:\n%s", text)64 t.Fatalf("result text missing inherited model/provider:\n%s", text)
63 }65 }
64 66
65- agents := tool.Swarm.List()67+ agents := tool.Supervisor.List()
66 if len(agents) != 1 {68 if len(agents) != 1 {
67 t.Fatalf("spawned agents = %d, want 1", len(agents))69 t.Fatalf("spawned agents = %d, want 1", len(agents))
68 }70 }
69 if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" {71 if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" {
70 t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning)72 t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning)
71 }73 }
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+ }
72 }133 }
73 134
74-func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) {135+func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) {
75- tool := &SwarmSpawnTool{136+ tool := &SubagentSpawnTool{
76- Swarm: newTestSwarm(t),137+ Supervisor: newTestSupervisor(t),
77- Enabled: func() bool { return true },138+ Enabled: func() bool { return true },
78 ResolveSubagent: func(name string) (*subagents.Profile, error) {139 ResolveSubagent: func(name string) (*subagents.Profile, error) {
79 if name != "reviewer" {140 if name != "reviewer" {
80 return nil, nil141 return nil, nil
@@ -100,7 +161,7 @@ func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) {
100 t.Fatalf("result text missing %q:\n%s", want, text)161 t.Fatalf("result text missing %q:\n%s", want, text)
101 }162 }
102 }163 }
103- agents := tool.Swarm.List()164+ agents := tool.Supervisor.List()
104 if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" {165 if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" {
105 t.Fatalf("agent profile/reasoning = %#v", agents)166 t.Fatalf("agent profile/reasoning = %#v", agents)
106 }167 }
@@ -109,20 +170,21 @@ func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) {
109 }170 }
110 }171 }
111 172
112-func TestSwarmSpawnAppliesProfileFastModeRestriction(t *testing.T) {173+func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) {
113 root := t.TempDir()174 root := t.TempDir()
114 profileFastMode := false175 profileFastMode := false
115- f := swarm.New(swarm.Config{176+ f := subagents.New(subagents.Config{
116- Root: filepath.Join(root, "swarm"),177+ Root: filepath.Join(root, "subagents"),
117 RepoRoot: root,178 RepoRoot: root,
118 FastMode: true,179 FastMode: true,
119- NewRunner: func(*swarm.Agent) swarm.Runner {180+ NewRunner: func(*subagents.Agent) subagents.Runner {
120- return noopSwarmRunner{}181+ return noopSupervisorRunner{}
121 },182 },
122 })183 })
123- tool := &SwarmSpawnTool{184+ t.Cleanup(f.StopAll)
124- Swarm: f,185+ tool := &SubagentSpawnTool{
125- Enabled: func() bool { return true },186+ Supervisor: f,
187+ Enabled: func() bool { return true },
126 ResolveSubagent: func(name string) (*subagents.Profile, error) {188 ResolveSubagent: func(name string) (*subagents.Profile, error) {
127 if name != "reviewer" {189 if name != "reviewer" {
128 return nil, nil190 return nil, nil
@@ -147,10 +209,10 @@ func TestSwarmSpawnAppliesProfileFastModeRestriction(t *testing.T) {
147 }209 }
148 }210 }
149 211
150-func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {212+func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
151- tool := &SwarmSpawnTool{213+ tool := &SubagentSpawnTool{
152- Swarm: newTestSwarm(t),214+ Supervisor: newTestSupervisor(t),
153- Enabled: func() bool { return true },215+ Enabled: func() bool { return true },
154 ResolveSubagent: func(name string) (*subagents.Profile, error) {216 ResolveSubagent: func(name string) (*subagents.Profile, error) {
155 if name != "reviewer" {217 if name != "reviewer" {
156 return nil, nil218 return nil, nil
@@ -170,16 +232,16 @@ func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
170 if res.IsError {232 if res.IsError {
171 t.Fatalf("unexpected tool error: %s", textResult(res.Content))233 t.Fatalf("unexpected tool error: %s", textResult(res.Content))
172 }234 }
173- agents := tool.Swarm.List()235+ agents := tool.Supervisor.List()
174 if len(agents) != 1 || agents[0].Reasoning != "low" {236 if len(agents) != 1 || agents[0].Reasoning != "low" {
175 t.Fatalf("agent reasoning = %#v, want low", agents)237 t.Fatalf("agent reasoning = %#v, want low", agents)
176 }238 }
177 }239 }
178 240
179-func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) {241+func TestSubagentSpawnRejectsUnknownProfile(t *testing.T) {
180- tool := &SwarmSpawnTool{242+ tool := &SubagentSpawnTool{
181- Swarm: newTestSwarm(t),243+ Supervisor: newTestSupervisor(t),
182- Enabled: func() bool { return true },244+ Enabled: func() bool { return true },
183 ResolveSubagent: func(string) (*subagents.Profile, error) {245 ResolveSubagent: func(string) (*subagents.Profile, error) {
184 return nil, nil246 return nil, nil
185 },247 },
@@ -191,14 +253,14 @@ func TestSwarmSpawnRejectsUnknownProfile(t *testing.T) {
191 if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") {253 if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") {
192 t.Fatalf("result = %#v, want unknown profile error", res)254 t.Fatalf("result = %#v, want unknown profile error", res)
193 }255 }
194- if got := len(tool.Swarm.List()); got != 0 {256+ if got := len(tool.Supervisor.List()); got != 0 {
195 t.Fatalf("spawned agents = %d, want 0", got)257 t.Fatalf("spawned agents = %d, want 0", got)
196 }258 }
197 }259 }
198 260
199-func TestSwarmSpawnRejectsPartialModelProviderOverride(t *testing.T) {261+func TestSubagentSpawnRejectsPartialModelProviderOverride(t *testing.T) {
200- tool := &SwarmSpawnTool{262+ tool := &SubagentSpawnTool{
201- Swarm: newTestSwarm(t),263+ Supervisor: newTestSupervisor(t),
202 Enabled: func() bool { return true },264 Enabled: func() bool { return true },
203 DefaultModel: func() string { return "gpt-5" },265 DefaultModel: func() string { return "gpt-5" },
204 DefaultProvider: func() string { return "openai-codex" },266 DefaultProvider: func() string { return "openai-codex" },
@@ -214,7 +276,7 @@ func TestSwarmSpawnRejectsPartialModelProviderOverride(t *testing.T) {
214 if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") {276 if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") {
215 t.Fatalf("error text = %q", got)277 t.Fatalf("error text = %q", got)
216 }278 }
217- if got := len(tool.Swarm.List()); got != 0 {279+ if got := len(tool.Supervisor.List()); got != 0 {
218 t.Fatalf("spawned agents = %d, want 0", got)280 t.Fatalf("spawned agents = %d, want 0", got)
219 }281 }
220 }282 }
M packages/core/agent.go +44 -9
@@ -16,9 +16,13 @@ import (
16 // Agent is a stateful conversation bound to a provider client, a model,16 // Agent is a stateful conversation bound to a provider client, a model,
17 // and a set of tools.17 // and a set of tools.
18 type Agent struct {18 type Agent struct {
19- Client provider.Client19+ Client provider.Client
20- Model string20+ Model string
21- System string21+ // 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.
22 Tools Registry26 Tools Registry
23 MaxSteps int27 MaxSteps int
24 Reasoning string28 Reasoning string
@@ -264,6 +268,36 @@ func (a *Agent) SetTools(reg Registry) {
264 a.mu.Unlock()268 a.mu.Unlock()
265 }269 }
266 270
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+
267 // SetMessages replaces the transcript (used when resuming a session).301 // SetMessages replaces the transcript (used when resuming a session).
268 func (a *Agent) SetMessages(msgs []provider.Message) {302 func (a *Agent) SetMessages(msgs []provider.Message) {
269 a.mu.Lock()303 a.mu.Lock()
@@ -594,7 +628,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
594 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {628 if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {
595 return provider.StopError, provider.Message{}, err629 return provider.StopError, provider.Message{}, err
596 }630 }
597- system := a.System631+ system, tools := a.PromptConfig()
598 if contextText := boundedTurnContext(turnContext); contextText != "" {632 if contextText := boundedTurnContext(turnContext); contextText != "" {
599 if system != "" {633 if system != "" {
600 system += "\n\n"634 system += "\n\n"
@@ -613,7 +647,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
613 // with "tool_use ids were found without tool_result blocks". The647 // with "tool_use ids were found without tool_result blocks". The
614 // repair is pure and a no-op on already-valid transcripts.648 // repair is pure and a no-op on already-valid transcripts.
615 Messages: repairToolUseResultPairs(a.Messages()),649 Messages: repairToolUseResultPairs(a.Messages()),
616- Tools: a.Tools.Specs(),650+ Tools: tools.Specs(),
617 Reasoning: a.Reasoning,651 Reasoning: a.Reasoning,
618 FastMode: fastMode,652 FastMode: fastMode,
619 MaxTokens: a.MaxTokens,653 MaxTokens: a.MaxTokens,
@@ -723,13 +757,14 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
723 var results []provider.Content757 var results []provider.Content
724 var addedTools []string758 var addedTools []string
725 hadError := false759 hadError := false
760+ tools := a.ToolsSnapshot()
726 761
727 for _, c := range msg.Content {762 for _, c := range msg.Content {
728 tc, ok := c.(provider.ToolCallBlock)763 tc, ok := c.(provider.ToolCallBlock)
729 if !ok {764 if !ok {
730 continue765 continue
731 }766 }
732- res := a.runOneTool(ctx, tc, sink)767+ res := a.runOneTool(ctx, tc, tools, sink)
733 if res.IsError {768 if res.IsError {
734 hadError = true769 hadError = true
735 }770 }
@@ -739,7 +774,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
739 IsError: res.IsError,774 IsError: res.IsError,
740 })775 })
741 for _, name := range res.ActivateTools {776 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) {
743 addedTools = append(addedTools, name)778 addedTools = append(addedTools, name)
744 }779 }
745 }780 }
@@ -757,8 +792,8 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
757 }, hadError792 }, hadError
758 }793 }
759 794
760-func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, sink func(AgentEvent)) ToolResult {795+func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) ToolResult {
761- tool, err := a.Tools.Get(tc.Name)796+ tool, err := tools.Get(tc.Name)
762 if err != nil {797 if err != nil {
763 return ToolResult{798 return ToolResult{
764 Content: []provider.Content{provider.TextBlock{Text: err.Error()}},799 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) {
116 }116 }
117 }117 }
118 118
119-// TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's119+// TestNewSessionAtPathCreatesAtExplicitPath proves the subagent worker's
120 // session-persistence fallback works: NewSessionAtPath creates the120 // session-persistence fallback works: NewSessionAtPath creates the
121 // file (and parent dirs) at the exact path the caller chose,121 // file (and parent dirs) at the exact path the caller chose,
122 // independent of SessionsDir(root, cwd). Without this helper, the122 // independent of SessionsDir(root, cwd). Without this helper, the
123-// swarm child's --session <path> with a non-existent path would123+// subagent worker's --session <path> with a non-existent path would
124 // fall through to NewSession and write the session under a124 // fall through to NewSession and write the session under a
125 // different (autogenerated) name, leaving the supervisor-chosen125 // different (autogenerated) name, leaving the supervisor-chosen
126 // path empty.126 // path empty.
M packages/core/intercept_test.go +6 -3
@@ -39,11 +39,12 @@ func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
39 }39 }
40 40
41 ctx := context.Background()41 ctx := context.Background()
42+ tools := a.ToolsSnapshot()
42 res := a.runOneTool(ctx, provider.ToolCallBlock{43 res := a.runOneTool(ctx, provider.ToolCallBlock{
43 ID: "T1",44 ID: "T1",
44 Name: "echo",45 Name: "echo",
45 Arguments: json.RawMessage(`{"command":"ls"}`),46 Arguments: json.RawMessage(`{"command":"ls"}`),
46- }, func(AgentEvent) {})47+ }, tools, func(AgentEvent) {})
47 if res.IsError {48 if res.IsError {
48 t.Fatalf("unexpected error result: %v", res.Content)49 t.Fatalf("unexpected error result: %v", res.Content)
49 }50 }
@@ -65,12 +66,13 @@ func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
65 }66 }
66 67
67 ctx := context.Background()68 ctx := context.Background()
69+ tools := a.ToolsSnapshot()
68 orig := json.RawMessage(`{"command":"ls"}`)70 orig := json.RawMessage(`{"command":"ls"}`)
69 a.runOneTool(ctx, provider.ToolCallBlock{71 a.runOneTool(ctx, provider.ToolCallBlock{
70 ID: "T1",72 ID: "T1",
71 Name: "echo",73 Name: "echo",
72 Arguments: orig,74 Arguments: orig,
73- }, func(AgentEvent) {})75+ }, tools, func(AgentEvent) {})
74 if string(rec.lastArgs) != string(orig) {76 if string(rec.lastArgs) != string(orig) {
75 t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig))77 t.Errorf("tool saw %s, want original %s", string(rec.lastArgs), string(orig))
76 }78 }
@@ -88,11 +90,12 @@ func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
88 }90 }
89 91
90 ctx := context.Background()92 ctx := context.Background()
93+ tools := a.ToolsSnapshot()
91 res := a.runOneTool(ctx, provider.ToolCallBlock{94 res := a.runOneTool(ctx, provider.ToolCallBlock{
92 ID: "T1",95 ID: "T1",
93 Name: "echo",96 Name: "echo",
94 Arguments: json.RawMessage(`{"command":"ls"}`),97 Arguments: json.RawMessage(`{"command":"ls"}`),
95- }, func(AgentEvent) {})98+ }, tools, func(AgentEvent) {})
96 if !res.IsError {99 if !res.IsError {
97 t.Fatal("want error result, got success")100 t.Fatal("want error result, got success")
98 }101 }
M packages/core/session.go +1 -1
@@ -153,7 +153,7 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error
153 }153 }
154 154
155 // NewSessionAtPath creates a session at an explicit file path. Used155 // NewSessionAtPath creates a session at an explicit file path. Used
156-// by callers (notably the swarm-agent child) that need the session156+// by callers (notably the subagent worker) that need the session
157 // file to live at a path chosen by their parent rather than under157 // file to live at a path chosen by their parent rather than under
158 // SessionsDir. Returns an error if the file already exists — use158 // SessionsDir. Returns an error if the file already exists — use
159 // OpenSession for that case.159 // 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
476 // alternative — always writing SeqHideCursor + cursor-position +476 // alternative — always writing SeqHideCursor + cursor-position +
477 // SeqShowCursor — resets the terminal's cursor blink timer on477 // SeqShowCursor — resets the terminal's cursor blink timer on
478 // every tick. At our 120ms animation cadence that means the478 // every tick. At our 120ms animation cadence that means the
479- // caret in an idle dialog editor (e.g. an open swarm transcript479+ // caret in an idle dialog editor (e.g. an open subagent transcript
480 // for an agent that's currently idle) appears as a solid block480 // for an agent that's currently idle) appears as a solid block
481 // that never blinks, because we keep "showing" it before the481 // that never blinks, because we keep "showing" it before the
482 // terminal can blink it off. Bailing out here lets the OS run482 // 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 (
14 // used to always emit SeqHideCursor + cursor-position +14 // used to always emit SeqHideCursor + cursor-position +
15 // SeqShowCursor, which resets the terminal's blink timer. Faster15 // SeqShowCursor, which resets the terminal's blink timer. Faster
16 // than the OS blink interval, so an idle dialog editor (e.g. a16 // 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)
18 // rendered the caret as a solid non-blinking block.18 // rendered the caret as a solid non-blinking block.
19 //19 //
20 // With the no-op fast path the renderer leaves the screen alone20 // 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) {
57 }57 }
58 }58 }
59 59
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+
60 func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {78 func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {
61 lines := StatusBar(StatusBarParams{79 lines := StatusBar(StatusBarParams{
62 Theme: Dark,80 Theme: Dark,
M packages/tui/view.go +34 -10
@@ -2408,6 +2408,7 @@ type StatusBarParams struct {
2408 Provider string2408 Provider string
2409 Model string2409 Model string
2410 Reasoning string // "" means thinking off2410 Reasoning string // "" means thinking off
2411+ FastMode bool // show the provider's opt-in fast tier when enabled
2411 Busy bool2412 Busy bool
2412 BusyPrefix string // spinner + funny line when busy2413 BusyPrefix string // spinner + funny line when busy
2413 CWD string2414 CWD string
@@ -2509,8 +2510,15 @@ func StatusBar(p StatusBarParams) []string {
2509 if reasoning != "" {2510 if reasoning != "" {
2510 reasoningText = "reasoning: " + reasoning2511 reasoningText = "reasoning: " + reasoning
2511 }2512 }
2513+ fastText := ""
2514+ if p.FastMode {
2515+ fastText = "fast mode"
2516+ }
2512 statsText := strings.Join(stats, " ")2517 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+ }
2514 if reasoningText != "" {2522 if reasoningText != "" {
2515 middleParts = append(middleParts, reasoningText)2523 middleParts = append(middleParts, reasoningText)
2516 }2524 }
@@ -2570,7 +2578,7 @@ func StatusBar(p StatusBarParams) []string {
2570 modelLine := pad + th.FG256(th.Muted, left)2578 modelLine := pad + th.FG256(th.Muted, left)
2571 lines := []string{busyLine}2579 lines := []string{busyLine}
2572 if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {2580 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)
2574 } else {2582 } else {
2575 var infoBuilder strings.Builder2583 var infoBuilder strings.Builder
2576 infoBuilder.WriteString(modelLine)2584 infoBuilder.WriteString(modelLine)
@@ -2592,7 +2600,7 @@ func StatusBar(p StatusBarParams) []string {
2592 // into an awkward position on small widths.2600 // into an awkward position on small widths.
2593 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {2601 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
2594 var lines []string2602 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)
2596 if cwd != "" {2604 if cwd != "" {
2597 lines = append(lines, pad+th.FG256(th.Muted, cwd))2605 lines = append(lines, pad+th.FG256(th.Muted, cwd))
2598 }2606 }
@@ -2609,9 +2617,17 @@ func StatusBar(p StatusBarParams) []string {
2609 return []string{primary, cwdRendered}2617 return []string{primary, cwdRendered}
2610 }2618 }
2611 2619
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 {
2613 modelLine := pad + th.FG256(th.Muted, modelText)2621 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 == "" {
2615 lines = append(lines, modelLine)2631 lines = append(lines, modelLine)
2616 if statsText != "" {2632 if statsText != "" {
2617 lines = append(lines, pad+th.FG256(th.Muted, statsText))2633 lines = append(lines, pad+th.FG256(th.Muted, statsText))
@@ -2619,13 +2635,21 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, reasonin
2619 return lines2635 return lines
2620 }2636 }
2621 2637
2622- modelReasoningPlain := pad + modelText + pad + reasoningText2638+ modelInfoPlain := pad + modelText + pad + infoText
2623- reasoningColor := reasoningStatusColor(th, reasoningText)2639+ infoColor := th.Muted
2624- if cols <= 0 || visibleWidth(modelReasoningPlain) <= cols {2640+ if reasoningText != "" {
2625- lines = append(lines, pad+th.FG256(reasoningColor, modelText+pad+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))
2626 } else {2645 } else {
2627 lines = append(lines, modelLine)2646 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+ }
2629 }2653 }
2630 if statsText != "" {2654 if statsText != "" {
2631 lines = append(lines, pad+th.FG256(th.Muted, statsText))2655 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