detached 1aea23e

swarm: drop git-worktree / isolation; agents share the host cwd

Each swarm subagent now runs with cwd == the parent zot's RepoRoot, just
like the main agent. No per-agent git worktree, no swarm/<id> branch, no
SetIsolation toggle, no '\''i'\'' dashboard shortcut, no --isolated flag. The
previous worktree flow was confusing (toggling '\''i'\'' on a running agent
couldn'\''t reseat its cwd, so edits kept landing in the host repo anyway)
and shipped without a real use case.

Concretely:

- delete internal/swarm/worktree.go and the WorktreeManager interface.
- Config loses Worktree; SpawnReq loses Isolated; Agent loses Branch and
  Isolated; AgentSnapshot loses Branch and Isolated; agentMeta loses
  branch and isolated (older meta.json files still decode \u2014 unknown JSON
  keys are ignored \u2014 and buildDetachedAgent coerces any stale per-
  worktree Dir back to the live RepoRoot so detached agents resume in
  the right place).
- Swarm.Remove no longer calls into any worktree manager, so it can'\''t
  accidentally git-worktree-remove the user'\''s actual source tree; it
  only clears <swarm-root>/agents/<id>/.
- runner.go drops the <Dir>/.zot/session.json fallback (every plausible
  Dir is now the user'\''s repo, where a stray .zot/ would litter the
  source tree); SessionPath is required and Spawn always populates it
  under <swarm-root>/agents/<id>/session.json.
- swarm dialog: remove isolate/SetIsolateFunc, the '\''i'\'' key handler, the
  MODE column, the mode/branch lines in the transcript header. Fix the
  transcript-view cursor row math (row += 4 was counting a now-removed
  branch row, leaving the caret one row above the editor accent bar).
- swarm slash command: drop /swarm isolate, /swarm unisolate, and the
  --isolated flag on /swarm new; trim the spawn-flag parser and tests.
- README and slash-suggest description updated; site copy updated in a
  separate commit.

Tests adjusted accordingly; full suite green.
commit
1aea23e419f122901935fc81fa45a4697cb67953
author
patriceckhart · · 2026-05-16 22:01 UTC
parent
63e28ad
signature
unsigned
refs
tag: v0.1.81
14 files · +166 -247
M README.md +25 -23
@@ -187,7 +187,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
187187 | `/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`. |
188188 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
189189 | `/btw` | Side chat with full context that doesn't add to the main thread. |
190-| `/swarm` | Spawn, monitor, and chat with background subagents. Each gets its own git worktree and runs in parallel with your main session. |
190+| `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. |
191191 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
192192 | `/compact` | Summarize the transcript into one message to free up context. |
193193 | `/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. |
@@ -210,7 +210,7 @@ Four ops on the current session. `/session` alone opens a picker; each is also r
210210
211211 - **`/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.
212212
213- **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, worktrees (which are real git checkouts under `$ZOT_HOME/swarm/worktrees/<id>` on branch `swarm/<id>`), unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to bundle a git worktree or revive a unix socket on another box. If you want to share a subagent's work, use normal git tooling on its branch (`git push`, `git format-patch`, etc.); if you want the conversation, copy it out of the dashboard manually.
213+ **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, 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.
214214 - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions`, `/jump`, and the tree. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically).
215215 - **`/session fork`**. Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies every message up to and including that turn into a new session, records `parent` + `fork_point` in the new meta, and switches onto the branch. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path.
216216 - **`/session tree`**. Shows every session in the current cwd arranged by parent/child relationships, depth-first with indent per level. The current session is tagged `[current]`. Pick any entry to switch into it. Parentless sessions are roots; branches created via `/session fork` nest under whichever session they were forked from. Orphaned children (whose parent file was deleted) still show as roots so they stay discoverable.
@@ -236,45 +236,47 @@ Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes th
236236
237237 ### `/swarm`
238238
239-Long-running parallel subagents. Each one gets its own git worktree off your current repo (branch `swarm/<id>`), its own persistent session file, and its own background subprocess driving the model. You stay in the main session and check in on them whenever you want — the dashboard is a `/btw`-style chat per agent.
239+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.
240240
241-Why: “go write the test harness for module X” / “investigate this stack trace” / “draft the migration” are perfect side tasks. Spawn an agent, keep working in your main session, come back to its results later — or chat with it interactively while it works.
241+> **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.
242242
243243 ```
244244 /swarm # open the dashboard
245-/swarm new <task> # spawn an agent on a fresh worktree
246-/swarm new --model gpt-5 <task> # pin the new agent to a specific model
245+/swarm new <task> # spawn an agent
246+/swarm new --model gpt-5 <task> # pin the new agent to a specific model
247247 /swarm logs <id> # jump straight into one agent's transcript
248248 /swarm send <id> <text> # send a follow-up without opening the dashboard
249249 /swarm resume # pick a stopped agent to bring back
250250 /swarm resume <id> # bring a specific agent back
251-/swarm kill <id> # stop a running agent (worktree stays)
252-/swarm remove <id> # delete the worktree + session for an agent
251+/swarm kill <id> # stop a running agent (its state stays)
252+/swarm remove <id> # delete the agent's session and state
253253 /swarm list # alias for opening the dashboard
254254 ```
255255
256-**Dashboard (`/swarm` with no arg)** — a list of every agent for the current session with status, age, and current activity. Keys:
256+**Dashboard (`/swarm` with no arg)** — a list of every agent for the current session, with status, age, and current activity. Keys:
257257
258-- `↑` / `↓` move the cursor between rows.
259-- `enter` opens the highlighted agent's transcript view.
260-- `n` spawns a new agent. Type the task, `enter` to confirm; the new agent inherits the model your main session is currently on (see `/model` and the in-editor `/model` command below).
261-- `p` opens a one-off prompt editor for the selected row (alternative to entering the transcript).
262-- `R` resumes a stopped agent in place.
263-- `k` kills the selected running agent (its worktree and session stay so you can resume later).
264-- `r` removes the selected agent entirely (worktree + session + meta gone).
265-- `esc` closes the dashboard.
258+| Key | Action |
259+|---|---|
260+| `↑` / `↓` | Move cursor between rows. |
261+| `enter` | Open the highlighted agent's transcript view. |
262+| `n` | Spawn a new agent (opens an inline task editor; inherits the host's current model). |
263+| `p` | One-off prompt editor for the selected row (without entering the transcript). |
264+| `R` | Resume a stopped agent in place. |
265+| `k` | Kill the selected running agent. Its session and state stay so you can resume it later. |
266+| `r` | Remove the selected agent entirely (session + meta gone). |
267+| `esc` | Close the dashboard. |
266268
267-**Inside an agent's transcript** — a chat overlay just like `/btw`. The agent's conversation flows above an always-on inline composer; type and hit `enter` to send a follow-up. The view auto-follows streaming output and shows an inline spinner with the agent's current activity (`thinking`, `tool: edit_file`, etc.) while it's busy. `esc` returns to the dashboard.
269+**Inside an agent's transcript** — a chat overlay with an always-on inline composer at the bottom. The conversation flows above it; type and `enter` to send a follow-up. The view auto-follows streaming output and shows an inline spinner with the agent's current activity (`thinking`, `tool: edit_file`, etc.) while it's busy. `esc` returns to the dashboard.
268270
269-**Switching the spawn model from inside the editor** — while composing a task in the `n`-prompt, type `/model` on its own line and `enter`. The same model picker the global `/model` uses pops up; pick a model, the picker closes, and the editor reopens with your typed task intact and the new model pinned for the spawn.
271+**Switching the spawn model from inside the editor** — while composing a task in the `n`-prompt, type `/model` on its own line and `enter`. The standard `/model` picker pops up; pick a model, the picker closes, and the editor reopens with your typed task intact and the new model pinned for the spawn.
270272
271-**Session scoping** — each agent is stamped with the session that spawned it and only shows up in that session's `/swarm` dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents you spawned in another session keep running and reappear when you switch back. Pre-upgrade agents (no session stamp) are visible from every session as a safety net.
273+**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.
272274
273-**Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Resumed agents reattach to the same worktree, session, branch, and inbox socket, so the conversation continues from where it left off.
275+**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.
274276
275-**Where their work lives** — worktrees go under `$ZOT_HOME/swarm/worktrees/<id>` on the branch `swarm/<id>`. Use the normal git tooling to inspect, merge, or rebase (`git worktree list`, `git log swarm/<id>`, etc.). `/swarm remove` deletes both the worktree and the swarm bookkeeping.
277+**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`.
276278
277-**`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; swarm worktrees and per-agent state are machine-local (a real git worktree on disk and a unix-socket inbox, neither of which round-trips through a JSONL file). To share a subagent's actual work, push its `swarm/<id>` branch with the normal git tooling. To share what it said, copy it out of the transcript view manually.
279+**`/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.
278280
279281 ### `/skills`
280282
@@ -187,7 +187,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
187 | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. |187 | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. |
188 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |188 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
189 | `/btw` | Side chat with full context that doesn't add to the main thread. |189 | `/btw` | Side chat with full context that doesn't add to the main thread. |
190-| `/swarm` | Spawn, monitor, and chat with background subagents. Each gets its own git worktree and runs in parallel with your main session. |190+| `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. |
191 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |191 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
192 | `/compact` | Summarize the transcript into one message to free up context. |192 | `/compact` | Summarize the transcript into one message to free up context. |
193 | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. |193 | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. |
@@ -210,7 +210,7 @@ Four ops on the current session. `/session` alone opens a picker; each is also r
210 210
211 - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout.211 - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout.
212 212
213- **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, worktrees (which are real git checkouts under `$ZOT_HOME/swarm/worktrees/<id>` on branch `swarm/<id>`), unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to bundle a git worktree or revive a unix socket on another box. If you want to share a subagent's work, use normal git tooling on its branch (`git push`, `git format-patch`, etc.); if you want the conversation, copy it out of the dashboard manually.213+ **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, 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.
214 - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions`, `/jump`, and the tree. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically).214 - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions`, `/jump`, and the tree. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically).
215 - **`/session fork`**. Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies every message up to and including that turn into a new session, records `parent` + `fork_point` in the new meta, and switches onto the branch. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path.215 - **`/session fork`**. Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies every message up to and including that turn into a new session, records `parent` + `fork_point` in the new meta, and switches onto the branch. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path.
216 - **`/session tree`**. Shows every session in the current cwd arranged by parent/child relationships, depth-first with indent per level. The current session is tagged `[current]`. Pick any entry to switch into it. Parentless sessions are roots; branches created via `/session fork` nest under whichever session they were forked from. Orphaned children (whose parent file was deleted) still show as roots so they stay discoverable.216 - **`/session tree`**. Shows every session in the current cwd arranged by parent/child relationships, depth-first with indent per level. The current session is tagged `[current]`. Pick any entry to switch into it. Parentless sessions are roots; branches created via `/session fork` nest under whichever session they were forked from. Orphaned children (whose parent file was deleted) still show as roots so they stay discoverable.
@@ -236,45 +236,47 @@ Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes th
236 236
237 ### `/swarm`237 ### `/swarm`
238 238
239-Long-running parallel subagents. Each one gets its own git worktree off your current repo (branch `swarm/<id>`), its own persistent session file, and its own background subprocess driving the model. You stay in the main session and check in on them whenever you want — the dashboard is a `/btw`-style chat per agent.239+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.
240 240
241-Why: “go write the test harness for module X” / “investigate this stack trace” / “draft the migration” are perfect side tasks. Spawn an agent, keep working in your main session, come back to its results later — or chat with it interactively while it works.241+> **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.
242 242
243 ```243 ```
244 /swarm # open the dashboard244 /swarm # open the dashboard
245-/swarm new <task> # spawn an agent on a fresh worktree245+/swarm new <task> # spawn an agent
246-/swarm new --model gpt-5 <task> # pin the new agent to a specific model246+/swarm new --model gpt-5 <task> # pin the new agent to a specific model
247 /swarm logs <id> # jump straight into one agent's transcript247 /swarm logs <id> # jump straight into one agent's transcript
248 /swarm send <id> <text> # send a follow-up without opening the dashboard248 /swarm send <id> <text> # send a follow-up without opening the dashboard
249 /swarm resume # pick a stopped agent to bring back249 /swarm resume # pick a stopped agent to bring back
250 /swarm resume <id> # bring a specific agent back250 /swarm resume <id> # bring a specific agent back
251-/swarm kill <id> # stop a running agent (worktree stays)251+/swarm kill <id> # stop a running agent (its state stays)
252-/swarm remove <id> # delete the worktree + session for an agent252+/swarm remove <id> # delete the agent's session and state
253 /swarm list # alias for opening the dashboard253 /swarm list # alias for opening the dashboard
254 ```254 ```
255 255
256-**Dashboard (`/swarm` with no arg)** — a list of every agent for the current session with status, age, and current activity. Keys:256+**Dashboard (`/swarm` with no arg)** — a list of every agent for the current session, with status, age, and current activity. Keys:
257 257
258-- `↑` / `↓` move the cursor between rows.258+| Key | Action |
259-- `enter` opens the highlighted agent's transcript view.259+|---|---|
260-- `n` spawns a new agent. Type the task, `enter` to confirm; the new agent inherits the model your main session is currently on (see `/model` and the in-editor `/model` command below).260+| `↑` / `↓` | Move cursor between rows. |
261-- `p` opens a one-off prompt editor for the selected row (alternative to entering the transcript).261+| `enter` | Open the highlighted agent's transcript view. |
262-- `R` resumes a stopped agent in place.262+| `n` | Spawn a new agent (opens an inline task editor; inherits the host's current model). |
263-- `k` kills the selected running agent (its worktree and session stay so you can resume later).263+| `p` | One-off prompt editor for the selected row (without entering the transcript). |
264-- `r` removes the selected agent entirely (worktree + session + meta gone).264+| `R` | Resume a stopped agent in place. |
265-- `esc` closes the dashboard.265+| `k` | Kill the selected running agent. Its session and state stay so you can resume it later. |
266+| `r` | Remove the selected agent entirely (session + meta gone). |
267+| `esc` | Close the dashboard. |
266 268
267-**Inside an agent's transcript** — a chat overlay just like `/btw`. The agent's conversation flows above an always-on inline composer; type and hit `enter` to send a follow-up. The view auto-follows streaming output and shows an inline spinner with the agent's current activity (`thinking`, `tool: edit_file`, etc.) while it's busy. `esc` returns to the dashboard.269+**Inside an agent's transcript** — a chat overlay with an always-on inline composer at the bottom. The conversation flows above it; type and `enter` to send a follow-up. The view auto-follows streaming output and shows an inline spinner with the agent's current activity (`thinking`, `tool: edit_file`, etc.) while it's busy. `esc` returns to the dashboard.
268 270
269-**Switching the spawn model from inside the editor** — while composing a task in the `n`-prompt, type `/model` on its own line and `enter`. The same model picker the global `/model` uses pops up; pick a model, the picker closes, and the editor reopens with your typed task intact and the new model pinned for the spawn.271+**Switching the spawn model from inside the editor** — while composing a task in the `n`-prompt, type `/model` on its own line and `enter`. The standard `/model` picker pops up; pick a model, the picker closes, and the editor reopens with your typed task intact and the new model pinned for the spawn.
270 272
271-**Session scoping** — each agent is stamped with the session that spawned it and only shows up in that session's `/swarm` dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents you spawned in another session keep running and reappear when you switch back. Pre-upgrade agents (no session stamp) are visible from every session as a safety net.273+**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.
272 274
273-**Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Resumed agents reattach to the same worktree, session, branch, and inbox socket, so the conversation continues from where it left off.275+**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.
274 276
275-**Where their work lives** — worktrees go under `$ZOT_HOME/swarm/worktrees/<id>` on the branch `swarm/<id>`. Use the normal git tooling to inspect, merge, or rebase (`git worktree list`, `git log swarm/<id>`, etc.). `/swarm remove` deletes both the worktree and the swarm bookkeeping.277+**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`.
276 278
277-**`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; swarm worktrees and per-agent state are machine-local (a real git worktree on disk and a unix-socket inbox, neither of which round-trips through a JSONL file). To share a subagent's actual work, push its `swarm/<id>` branch with the normal git tooling. To share what it said, copy it out of the transcript view manually.279+**`/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.
278 280
279 ### `/skills`281 ### `/skills`
280 282
M internal/agent/modes/slash_suggest.go +1 -1
@@ -45,7 +45,7 @@ var slashCatalog = []slashCommand{
4545 {Name: "/jail", Desc: "confine tools to the current directory"},
4646 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},
4747 {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"},
48- {Name: "/swarm", Desc: "supervise background agents working in their own worktrees"},
48+ {Name: "/swarm", Desc: "supervise background agents that share this working directory"},
4949 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
5050 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
5151 {Name: "/clear", Desc: "clear the chat transcript"},
@@ -45,7 +45,7 @@ var slashCatalog = []slashCommand{
45 {Name: "/jail", Desc: "confine tools to the current directory"},45 {Name: "/jail", Desc: "confine tools to the current directory"},
46 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},46 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},
47 {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"},47 {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"},
48- {Name: "/swarm", Desc: "supervise background agents working in their own worktrees"},48+ {Name: "/swarm", Desc: "supervise background agents that share this working directory"},
49 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},49 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
50 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},50 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
51 {Name: "/clear", Desc: "clear the chat transcript"},51 {Name: "/clear", Desc: "clear the chat transcript"},
M internal/agent/modes/swarm_dialog.go +11 -6
@@ -23,7 +23,7 @@ import (
2323 // ↑/↓ move cursor
2424 // enter show transcript tail for the selected agent
2525 // k kill (Stop) the selected running agent
26-// r remove a terminated agent (deletes worktree)
26+// r remove a terminated agent (clears its state)
2727 // esc / q close
2828 //
2929 // Keys (transcript view):
@@ -44,8 +44,8 @@ type swarmDialog struct {
4444 // Wired by Open(); when nil the inline 'p' shortcut is disabled.
4545 send func(id, text string) error
4646 // resume restarts a detached or terminated agent on its existing
47- // worktree/session. Wired by Open(); when nil the inline 'R'
48- // shortcut is disabled.
47+ // session. Wired by Open(); when nil the inline 'R' shortcut is
48+ // disabled.
4949 resume func(id string) error
5050
5151 rows []swarm.AgentSnapshot
@@ -240,7 +240,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse
240240 return -1
241241 }
242242 row := 1 // frame header
243- row += 4 // task / branch / dir / status
243+ row += 3 // task / dir / status (mirrors renderTranscript's fixed header rows)
244244 if a.Model != "" {
245245 row++
246246 }
@@ -400,7 +400,7 @@ func promptDisabledHint(s swarm.Status) string {
400400 // killDisabledHint mirrors promptDisabledHint for the 'k' shortcut.
401401 // Kill only makes sense on running / pending agents; on detached and
402402 // terminal ones it's a no-op and the user usually wants 'r' (remove)
403-// to clean up the worktree instead.
403+// to clear out the agent's state instead.
404404 func killDisabledHint(s swarm.Status) string {
405405 return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)"
406406 }
@@ -1033,7 +1033,6 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {
10331033 header := []string{
10341034 frameHeader(th, "swarm: "+a.ID+" (type to send, esc back)", width),
10351035 " " + th.FG256(th.Muted, "task: "+a.Task),
1036- " " + th.FG256(th.Muted, "branch: "+a.Branch),
10371036 " " + th.FG256(th.Muted, "dir: "+a.Dir),
10381037 " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),
10391038 }
@@ -1321,6 +1320,12 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string)
13211320 }
13221321
13231322 // formatSwarmRow is the one-line summary shown per agent.
1323+//
1324+// Layout (fixed-width columns, then free-form activity):
1325+//
1326+// STATUS ID AGE ACTIVITY
1327+// ● run fix-login-12345 3m editing main.go
1328+// ✓ done write-tests-67890 1h done
13241329 func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {
13251330 status := statusLabel(r.Status)
13261331 age := formatAge(r.Started)
@@ -23,7 +23,7 @@ import (
23 // ↑/↓ move cursor23 // ↑/↓ move cursor
24 // enter show transcript tail for the selected agent24 // enter show transcript tail for the selected agent
25 // k kill (Stop) the selected running agent25 // k kill (Stop) the selected running agent
26-// r remove a terminated agent (deletes worktree)26+// r remove a terminated agent (clears its state)
27 // esc / q close27 // esc / q close
28 //28 //
29 // Keys (transcript view):29 // Keys (transcript view):
@@ -44,8 +44,8 @@ type swarmDialog struct {
44 // Wired by Open(); when nil the inline 'p' shortcut is disabled.44 // Wired by Open(); when nil the inline 'p' shortcut is disabled.
45 send func(id, text string) error45 send func(id, text string) error
46 // resume restarts a detached or terminated agent on its existing46 // resume restarts a detached or terminated agent on its existing
47- // worktree/session. Wired by Open(); when nil the inline 'R'47+ // session. Wired by Open(); when nil the inline 'R' shortcut is
48- // shortcut is disabled.48+ // disabled.
49 resume func(id string) error49 resume func(id string) error
50 50
51 rows []swarm.AgentSnapshot51 rows []swarm.AgentSnapshot
@@ -240,7 +240,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse
240 return -1240 return -1
241 }241 }
242 row := 1 // frame header242 row := 1 // frame header
243- row += 4 // task / branch / dir / status243+ row += 3 // task / dir / status (mirrors renderTranscript's fixed header rows)
244 if a.Model != "" {244 if a.Model != "" {
245 row++245 row++
246 }246 }
@@ -400,7 +400,7 @@ func promptDisabledHint(s swarm.Status) string {
400 // killDisabledHint mirrors promptDisabledHint for the 'k' shortcut.400 // killDisabledHint mirrors promptDisabledHint for the 'k' shortcut.
401 // Kill only makes sense on running / pending agents; on detached and401 // Kill only makes sense on running / pending agents; on detached and
402 // terminal ones it's a no-op and the user usually wants 'r' (remove)402 // terminal ones it's a no-op and the user usually wants 'r' (remove)
403-// to clean up the worktree instead.403+// to clear out the agent's state instead.
404 func killDisabledHint(s swarm.Status) string {404 func killDisabledHint(s swarm.Status) string {
405 return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)"405 return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)"
406 }406 }
@@ -1033,7 +1033,6 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {
1033 header := []string{1033 header := []string{
1034 frameHeader(th, "swarm: "+a.ID+" (type to send, esc back)", width),1034 frameHeader(th, "swarm: "+a.ID+" (type to send, esc back)", width),
1035 " " + th.FG256(th.Muted, "task: "+a.Task),1035 " " + th.FG256(th.Muted, "task: "+a.Task),
1036- " " + th.FG256(th.Muted, "branch: "+a.Branch),
1037 " " + th.FG256(th.Muted, "dir: "+a.Dir),1036 " " + th.FG256(th.Muted, "dir: "+a.Dir),
1038 " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),1037 " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),
1039 }1038 }
@@ -1321,6 +1320,12 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string)
1321 }1320 }
1322 1321
1323 // formatSwarmRow is the one-line summary shown per agent.1322 // formatSwarmRow is the one-line summary shown per agent.
1323+//
1324+// Layout (fixed-width columns, then free-form activity):
1325+//
1326+// STATUS ID AGE ACTIVITY
1327+// ● run fix-login-12345 3m editing main.go
1328+// ✓ done write-tests-67890 1h done
1324 func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {1329 func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {
1325 status := statusLabel(r.Status)1330 status := statusLabel(r.Status)
1326 age := formatAge(r.Started)1331 age := formatAge(r.Started)
M internal/agent/modes/swarm_dialog_test.go +1 -1
@@ -100,7 +100,7 @@ func TestSwarmDialogEnterShowsTranscript(t *testing.T) {
100100 _ = d.Render(tui.Theme{}, 80)
101101 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
102102 out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
103- for _, want := range []string{"task:", "branch:", "line a", "line b"} {
103+ for _, want := range []string{"task:", "dir:", "line a", "line b"} {
104104 if !strings.Contains(out, want) {
105105 t.Fatalf("transcript view missing %q:\n%s", want, out)
106106 }
@@ -100,7 +100,7 @@ func TestSwarmDialogEnterShowsTranscript(t *testing.T) {
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})
102 out := strings.Join(d.Render(tui.Theme{}, 80), "\n")102 out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
103- for _, want := range []string{"task:", "branch:", "line a", "line b"} {103+ for _, want := range []string{"task:", "dir:", "line a", "line b"} {
104 if !strings.Contains(out, want) {104 if !strings.Contains(out, want) {
105 t.Fatalf("transcript view missing %q:\n%s", want, out)105 t.Fatalf("transcript view missing %q:\n%s", want, out)
106 }106 }
M internal/agent/modes/swarm_slash.go +2 -2
@@ -110,9 +110,9 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
110110 return
111111 }
112112 if model != "" {
113- i.swarmStatus("spawned "+a.ID+" on "+a.Branch+" (model "+model+")", "")
113+ i.swarmStatus("spawned "+a.ID+" (model "+model+")", "")
114114 } else {
115- i.swarmStatus("spawned "+a.ID+" on "+a.Branch, "")
115+ i.swarmStatus("spawned "+a.ID, "")
116116 }
117117 case "kill", "stop":
118118 if rest == "" {
@@ -110,9 +110,9 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) {
110 return110 return
111 }111 }
112 if model != "" {112 if model != "" {
113- i.swarmStatus("spawned "+a.ID+" on "+a.Branch+" (model "+model+")", "")113+ i.swarmStatus("spawned "+a.ID+" (model "+model+")", "")
114 } else {114 } else {
115- i.swarmStatus("spawned "+a.ID+" on "+a.Branch, "")115+ i.swarmStatus("spawned "+a.ID, "")
116 }116 }
117 case "kill", "stop":117 case "kill", "stop":
118 if rest == "" {118 if rest == "" {
M internal/agent/modes/swarm_slash_test.go +0 -3
@@ -2,7 +2,6 @@ package modes
22
33 import (
44 "context"
5- "path/filepath"
65 "testing"
76 "time"
87
@@ -19,7 +18,6 @@ func newInteractiveForSwarmTest(t *testing.T) (*Interactive, *swarm.Swarm) {
1918 f := swarm.New(swarm.Config{
2019 Root: root,
2120 RepoRoot: root,
22- Worktree: swarm.MemWorktree(filepath.Join(root, "wt")),
2321 NewRunner: func(a *swarm.Agent) swarm.Runner {
2422 return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {
2523 <-ctx.Done()
@@ -107,7 +105,6 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) {
107105 f := swarm.New(swarm.Config{
108106 Root: root,
109107 RepoRoot: root,
110- Worktree: swarm.MemWorktree(filepath.Join(root, "wt")),
111108 NewRunner: func(a *swarm.Agent) swarm.Runner {
112109 return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {
113110 // Stand up a real Listener on the agent's inbox path so
@@ -2,7 +2,6 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5- "path/filepath"
6 "testing"5 "testing"
7 "time"6 "time"
8 7
@@ -19,7 +18,6 @@ func newInteractiveForSwarmTest(t *testing.T) (*Interactive, *swarm.Swarm) {
19 f := swarm.New(swarm.Config{18 f := swarm.New(swarm.Config{
20 Root: root,19 Root: root,
21 RepoRoot: root,20 RepoRoot: root,
22- Worktree: swarm.MemWorktree(filepath.Join(root, "wt")),
23 NewRunner: func(a *swarm.Agent) swarm.Runner {21 NewRunner: func(a *swarm.Agent) swarm.Runner {
24 return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {22 return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {
25 <-ctx.Done()23 <-ctx.Done()
@@ -107,7 +105,6 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) {
107 f := swarm.New(swarm.Config{105 f := swarm.New(swarm.Config{
108 Root: root,106 Root: root,
109 RepoRoot: root,107 RepoRoot: root,
110- Worktree: swarm.MemWorktree(filepath.Join(root, "wt")),
111 NewRunner: func(a *swarm.Agent) swarm.Runner {108 NewRunner: func(a *swarm.Agent) swarm.Runner {
112 return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {109 return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {
113 // Stand up a real Listener on the agent's inbox path so110 // Stand up a real Listener on the agent's inbox path so
M internal/swarm/agent.go +1 -2
@@ -14,8 +14,7 @@ import (
1414 type Agent struct {
1515 ID string
1616 Task string
17- Branch string
18- Dir string
17+ Dir string // always the host's RepoRoot; agents share its cwd.
1918 Started time.Time
2019
2120 // Model and Provider, when non-empty, override the child
@@ -14,8 +14,7 @@ import (
14 type Agent struct {14 type Agent struct {
15 ID string15 ID string
16 Task string16 Task string
17- Branch string17+ Dir string // always the host's RepoRoot; agents share its cwd.
18- Dir string
19 Started time.Time18 Started time.Time
20 19
21 // Model and Provider, when non-empty, override the child20 // Model and Provider, when non-empty, override the child
M internal/swarm/persist.go +17 -8
@@ -33,10 +33,13 @@ import (
3333 // the supervisor needs to rebuild an Agent after a restart live here.
3434 // Adding a field is backwards-compatible (older meta.json files just
3535 // 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.
3640 type agentMeta struct {
3741 ID string `json:"id"`
3842 Task string `json:"task"`
39- Branch string `json:"branch"`
4043 Dir string `json:"dir"`
4144 Started time.Time `json:"started"`
4245 Model string `json:"model,omitempty"`
@@ -51,8 +54,7 @@ type agentMeta struct {
5154 // (and agents spawned outside of any session, e.g. by tests or
5255 // scripted callers that didn't call SetActiveSession) have an
5356 // empty SessionID and are visible from every session as a
54- // backward-compat fallback. Added in 2026 — a fresh field on a
55- // json struct is backwards-compatible by design.
57+ // backward-compat fallback.
5658 SessionID string `json:"session_id,omitempty"`
5759 }
5860
@@ -65,7 +67,6 @@ func writeAgentMeta(stateDir string, a *Agent) error {
6567 m := agentMeta{
6668 ID: a.ID,
6769 Task: a.Task,
68- Branch: a.Branch,
6970 Dir: a.Dir,
7071 Started: a.Started,
7172 Model: a.Model,
@@ -187,11 +188,20 @@ func (f *Swarm) Reload() (loaded int, errs []error) {
187188 // The returned Agent has a closed `done` channel because Wait should
188189 // return instantly: there is nothing to wait for.
189190 func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
191+ // Older meta.json files may still record a per-agent worktree
192+ // path under Dir. They predate the decision to run every agent
193+ // in the host's repo and shouldn't continue editing that stale
194+ // checkout, which most likely no longer matches HEAD. Coerce
195+ // the dir back to the live RepoRoot so resume picks up where
196+ // the host is now.
197+ dir := m.Dir
198+ if f.cfg.RepoRoot != "" {
199+ dir = f.cfg.RepoRoot
200+ }
190201 a := &Agent{
191202 ID: m.ID,
192203 Task: m.Task,
193- Branch: m.Branch,
194- Dir: m.Dir,
204+ Dir: dir,
195205 Started: m.Started,
196206 Model: m.Model,
197207 Provider: m.Provider,
@@ -334,7 +344,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
334344 // (e.g. tests that hand-built an Agent) don't accidentally route
335345 // the new runner at the wrong paths.
336346 m := agentMeta{
337- ID: existing.ID, Task: existing.Task, Branch: existing.Branch,
347+ ID: existing.ID, Task: existing.Task,
338348 Dir: existing.Dir, Started: existing.Started,
339349 Model: existing.Model, Provider: existing.Provider,
340350 InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath,
@@ -344,7 +354,6 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
344354 a := &Agent{
345355 ID: m.ID,
346356 Task: m.Task,
347- Branch: m.Branch,
348357 Dir: m.Dir,
349358 Started: m.Started,
350359 Model: m.Model,
@@ -33,10 +33,13 @@ import (
33 // the supervisor needs to rebuild an Agent after a restart live here.33 // the supervisor needs to rebuild an Agent after a restart live here.
34 // Adding a field is backwards-compatible (older meta.json files just34 // Adding a field is backwards-compatible (older meta.json files just
35 // leave it zero); removing or renaming one is not.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.
36 type agentMeta struct {40 type agentMeta struct {
37 ID string `json:"id"`41 ID string `json:"id"`
38 Task string `json:"task"`42 Task string `json:"task"`
39- Branch string `json:"branch"`
40 Dir string `json:"dir"`43 Dir string `json:"dir"`
41 Started time.Time `json:"started"`44 Started time.Time `json:"started"`
42 Model string `json:"model,omitempty"`45 Model string `json:"model,omitempty"`
@@ -51,8 +54,7 @@ type agentMeta struct {
51 // (and agents spawned outside of any session, e.g. by tests or54 // (and agents spawned outside of any session, e.g. by tests or
52 // scripted callers that didn't call SetActiveSession) have an55 // scripted callers that didn't call SetActiveSession) have an
53 // empty SessionID and are visible from every session as a56 // empty SessionID and are visible from every session as a
54- // backward-compat fallback. Added in 2026 — a fresh field on a57+ // backward-compat fallback.
55- // json struct is backwards-compatible by design.
56 SessionID string `json:"session_id,omitempty"`58 SessionID string `json:"session_id,omitempty"`
57 }59 }
58 60
@@ -65,7 +67,6 @@ func writeAgentMeta(stateDir string, a *Agent) error {
65 m := agentMeta{67 m := agentMeta{
66 ID: a.ID,68 ID: a.ID,
67 Task: a.Task,69 Task: a.Task,
68- Branch: a.Branch,
69 Dir: a.Dir,70 Dir: a.Dir,
70 Started: a.Started,71 Started: a.Started,
71 Model: a.Model,72 Model: a.Model,
@@ -187,11 +188,20 @@ func (f *Swarm) Reload() (loaded int, errs []error) {
187 // The returned Agent has a closed `done` channel because Wait should188 // The returned Agent has a closed `done` channel because Wait should
188 // return instantly: there is nothing to wait for.189 // return instantly: there is nothing to wait for.
189 func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {190 func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
191+ // Older meta.json files may still record a per-agent worktree
192+ // path under Dir. They predate the decision to run every agent
193+ // in the host's repo and shouldn't continue editing that stale
194+ // checkout, which most likely no longer matches HEAD. Coerce
195+ // the dir back to the live RepoRoot so resume picks up where
196+ // the host is now.
197+ dir := m.Dir
198+ if f.cfg.RepoRoot != "" {
199+ dir = f.cfg.RepoRoot
200+ }
190 a := &Agent{201 a := &Agent{
191 ID: m.ID,202 ID: m.ID,
192 Task: m.Task,203 Task: m.Task,
193- Branch: m.Branch,204+ Dir: dir,
194- Dir: m.Dir,
195 Started: m.Started,205 Started: m.Started,
196 Model: m.Model,206 Model: m.Model,
197 Provider: m.Provider,207 Provider: m.Provider,
@@ -334,7 +344,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
334 // (e.g. tests that hand-built an Agent) don't accidentally route344 // (e.g. tests that hand-built an Agent) don't accidentally route
335 // the new runner at the wrong paths.345 // the new runner at the wrong paths.
336 m := agentMeta{346 m := agentMeta{
337- ID: existing.ID, Task: existing.Task, Branch: existing.Branch,347+ ID: existing.ID, Task: existing.Task,
338 Dir: existing.Dir, Started: existing.Started,348 Dir: existing.Dir, Started: existing.Started,
339 Model: existing.Model, Provider: existing.Provider,349 Model: existing.Model, Provider: existing.Provider,
340 InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath,350 InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath,
@@ -344,7 +354,6 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
344 a := &Agent{354 a := &Agent{
345 ID: m.ID,355 ID: m.ID,
346 Task: m.Task,356 Task: m.Task,
347- Branch: m.Branch,
348 Dir: m.Dir,357 Dir: m.Dir,
349 Started: m.Started,358 Started: m.Started,
350 Model: m.Model,359 Model: m.Model,
M internal/swarm/persist_test.go +12 -26
@@ -21,7 +21,6 @@ func TestSpawnWritesMetaJSON(t *testing.T) {
2121 f := New(Config{
2222 Root: root,
2323 RepoRoot: root,
24- Worktree: MemWorktree(filepath.Join(root, "wt")),
2524 NewRunner: func(a *Agent) Runner {
2625 return RunnerFunc(func(ctx context.Context, _ Sink) error {
2726 <-ctx.Done()
@@ -51,7 +50,7 @@ func TestSpawnWritesMetaJSON(t *testing.T) {
5150 if got.Task != "investigate widget" {
5251 t.Errorf("meta.Task = %q", got.Task)
5352 }
54- if got.Branch != a.Branch || got.Dir != a.Dir {
53+ if got.Dir != a.Dir {
5554 t.Errorf("meta paths drifted: %+v vs agent %+v", got, a)
5655 }
5756 if got.InboxPath == "" || got.EventLogPath == "" || got.SessionPath == "" {
@@ -74,7 +73,6 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) {
7473 first := New(Config{
7574 Root: root,
7675 RepoRoot: root,
77- Worktree: MemWorktree(filepath.Join(root, "wt")),
7876 NewRunner: func(a *Agent) Runner {
7977 return RunnerFunc(func(ctx context.Context, _ Sink) error {
8078 <-ctx.Done()
@@ -101,7 +99,6 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) {
10199 second := New(Config{
102100 Root: root,
103101 RepoRoot: root,
104- Worktree: MemWorktree(filepath.Join(root, "wt")),
105102 })
106103 loaded, errs := second.Reload()
107104 if len(errs) > 0 {
@@ -140,7 +137,6 @@ func TestReloadIsIdempotent(t *testing.T) {
140137 root := t.TempDir()
141138 first := New(Config{
142139 Root: root, RepoRoot: root,
143- Worktree: MemWorktree(filepath.Join(root, "wt")),
144140 NewRunner: func(a *Agent) Runner {
145141 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
146142 },
@@ -150,7 +146,7 @@ func TestReloadIsIdempotent(t *testing.T) {
150146 }
151147 first.StopAll()
152148
153- second := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})
149+ second := New(Config{Root: root, RepoRoot: root})
154150 loaded1, _ := second.Reload()
155151 loaded2, errs := second.Reload()
156152 if len(errs) > 0 {
@@ -177,8 +173,8 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
177173 }
178174 // meta.json
179175 m := agentMeta{
180- ID: id, Task: "do thing", Branch: "swarm/alpha-9",
181- Dir: filepath.Join(root, "wt", id), Started: time.Now().Add(-time.Hour),
176+ ID: id, Task: "do thing",
177+ Dir: root, Started: time.Now().Add(-time.Hour),
182178 InboxPath: filepath.Join(stateDir, "in.sock"),
183179 EventLogPath: filepath.Join(stateDir, "events.jsonl"),
184180 SessionPath: filepath.Join(stateDir, "session.json"),
@@ -198,7 +194,7 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
198194 _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "shutdown"}))
199195 _ = log.Close()
200196
201- f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})
197+ f := New(Config{Root: root, RepoRoot: root})
202198 loaded, errs := f.Reload()
203199 if len(errs) > 0 || loaded != 1 {
204200 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
@@ -246,11 +242,11 @@ func TestReloadSkipsBareDirsAndCorruptMeta(t *testing.T) {
246242 good := "good-1"
247243 stateDir := filepath.Join(agentsDir, good)
248244 _ = os.MkdirAll(stateDir, 0o755)
249- m := agentMeta{ID: good, Task: "x", Branch: "swarm/" + good, Dir: "/tmp/x", Started: time.Now()}
245+ m := agentMeta{ID: good, Task: "x", Dir: "/tmp/x", Started: time.Now()}
250246 mb, _ := json.MarshalIndent(m, "", " ")
251247 _ = os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644)
252248
253- f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})
249+ f := New(Config{Root: root, RepoRoot: root})
254250 loaded, errs := f.Reload()
255251 if loaded != 1 {
256252 t.Errorf("loaded = %d; want 1", loaded)
@@ -276,7 +272,6 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
276272 )
277273 f := New(Config{
278274 Root: root, RepoRoot: root,
279- Worktree: MemWorktree(filepath.Join(root, "wt")),
280275 NewRunner: func(a *Agent) Runner {
281276 return RunnerFunc(func(ctx context.Context, sink Sink) error {
282277 mu.Lock()
@@ -358,7 +353,6 @@ func TestResumeSetsResumingFlag(t *testing.T) {
358353 root := t.TempDir()
359354 f := New(Config{
360355 Root: root, RepoRoot: root,
361- Worktree: MemWorktree(filepath.Join(root, "wt")),
362356 NewRunner: func(a *Agent) Runner {
363357 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
364358 },
@@ -392,7 +386,6 @@ func TestResumeRejectsRunningAgent(t *testing.T) {
392386 root := t.TempDir()
393387 f := New(Config{
394388 Root: root, RepoRoot: root,
395- Worktree: MemWorktree(filepath.Join(root, "wt")),
396389 NewRunner: func(a *Agent) Runner {
397390 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
398391 },
@@ -430,7 +423,6 @@ func TestResumeAfterReload(t *testing.T) {
430423 // Process A
431424 a := New(Config{
432425 Root: root, RepoRoot: root,
433- Worktree: MemWorktree(filepath.Join(root, "wt")),
434426 NewRunner: func(ag *Agent) Runner {
435427 return RunnerFunc(func(ctx context.Context, sink Sink) error {
436428 sink.Transcript("first run for " + ag.ID)
@@ -456,7 +448,6 @@ func TestResumeAfterReload(t *testing.T) {
456448 resumed := make(chan struct{}, 1)
457449 b := New(Config{
458450 Root: root, RepoRoot: root,
459- Worktree: MemWorktree(filepath.Join(root, "wt")),
460451 NewRunner: func(ag *Agent) Runner {
461452 return RunnerFunc(func(ctx context.Context, sink Sink) error {
462453 sink.Transcript("second run for " + ag.ID)
@@ -507,7 +498,6 @@ func TestSpawnReqPersistsModel(t *testing.T) {
507498 root := t.TempDir()
508499 f := New(Config{
509500 Root: root, RepoRoot: root,
510- Worktree: MemWorktree(filepath.Join(root, "wt")),
511501 NewRunner: func(a *Agent) Runner {
512502 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
513503 },
@@ -547,7 +537,7 @@ func TestSpawnReqPersistsModel(t *testing.T) {
547537 // Reload in a fresh Swarm and confirm the detached agent still
548538 // carries the model/provider so Resume can route the child
549539 // subprocess back to the same model.
550- g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})
540+ g := New(Config{Root: root, RepoRoot: root})
551541 if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {
552542 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
553543 }
@@ -605,8 +595,8 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) {
605595 t.Fatal(err)
606596 }
607597 m := agentMeta{
608- ID: id, Task: "t", Branch: "swarm/" + id,
609- Dir: filepath.Join(root, "wt", id),
598+ ID: id, Task: "t",
599+ Dir: root,
610600 Started: time.Now().Add(-time.Hour),
611601 InboxPath: filepath.Join(stateDir, "in.sock"),
612602 EventLogPath: filepath.Join(stateDir, "events.jsonl"),
@@ -617,7 +607,7 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) {
617607 t.Fatal(err)
618608 }
619609
620- f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})
610+ f := New(Config{Root: root, RepoRoot: root})
621611 if loaded, errs := f.Reload(); loaded != 1 || len(errs) > 0 {
622612 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
623613 }
@@ -654,7 +644,6 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) {
654644 root := t.TempDir()
655645 f := New(Config{
656646 Root: root, RepoRoot: root,
657- Worktree: MemWorktree(filepath.Join(root, "wt")),
658647 NewRunner: func(a *Agent) Runner {
659648 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
660649 },
@@ -679,7 +668,7 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) {
679668 }
680669
681670 // A fresh Swarm + Reload should find nothing.
682- g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})
671+ g := New(Config{Root: root, RepoRoot: root})
683672 if loaded, _ := g.Reload(); loaded != 0 {
684673 t.Fatalf("reload after remove loaded=%d; want 0", loaded)
685674 }
@@ -694,7 +683,6 @@ func TestActiveSessionScopesSnapshotAll(t *testing.T) {
694683 root := t.TempDir()
695684 f := New(Config{
696685 Root: root, RepoRoot: root,
697- Worktree: MemWorktree(filepath.Join(root, "wt")),
698686 NewRunner: func(a *Agent) Runner {
699687 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
700688 },
@@ -759,7 +747,6 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) {
759747 mkSwarm := func() *Swarm {
760748 return New(Config{
761749 Root: root, RepoRoot: root,
762- Worktree: MemWorktree(filepath.Join(root, "wt")),
763750 NewRunner: func(a *Agent) Runner {
764751 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
765752 },
@@ -803,7 +790,6 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) {
803790 root := t.TempDir()
804791 f := New(Config{
805792 Root: root, RepoRoot: root,
806- Worktree: MemWorktree(filepath.Join(root, "wt")),
807793 NewRunner: func(a *Agent) Runner {
808794 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
809795 },
@@ -21,7 +21,6 @@ func TestSpawnWritesMetaJSON(t *testing.T) {
21 f := New(Config{21 f := New(Config{
22 Root: root,22 Root: root,
23 RepoRoot: root,23 RepoRoot: root,
24- Worktree: MemWorktree(filepath.Join(root, "wt")),
25 NewRunner: func(a *Agent) Runner {24 NewRunner: func(a *Agent) Runner {
26 return RunnerFunc(func(ctx context.Context, _ Sink) error {25 return RunnerFunc(func(ctx context.Context, _ Sink) error {
27 <-ctx.Done()26 <-ctx.Done()
@@ -51,7 +50,7 @@ func TestSpawnWritesMetaJSON(t *testing.T) {
51 if got.Task != "investigate widget" {50 if got.Task != "investigate widget" {
52 t.Errorf("meta.Task = %q", got.Task)51 t.Errorf("meta.Task = %q", got.Task)
53 }52 }
54- if got.Branch != a.Branch || got.Dir != a.Dir {53+ if got.Dir != a.Dir {
55 t.Errorf("meta paths drifted: %+v vs agent %+v", got, a)54 t.Errorf("meta paths drifted: %+v vs agent %+v", got, a)
56 }55 }
57 if got.InboxPath == "" || got.EventLogPath == "" || got.SessionPath == "" {56 if got.InboxPath == "" || got.EventLogPath == "" || got.SessionPath == "" {
@@ -74,7 +73,6 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) {
74 first := New(Config{73 first := New(Config{
75 Root: root,74 Root: root,
76 RepoRoot: root,75 RepoRoot: root,
77- Worktree: MemWorktree(filepath.Join(root, "wt")),
78 NewRunner: func(a *Agent) Runner {76 NewRunner: func(a *Agent) Runner {
79 return RunnerFunc(func(ctx context.Context, _ Sink) error {77 return RunnerFunc(func(ctx context.Context, _ Sink) error {
80 <-ctx.Done()78 <-ctx.Done()
@@ -101,7 +99,6 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) {
101 second := New(Config{99 second := New(Config{
102 Root: root,100 Root: root,
103 RepoRoot: root,101 RepoRoot: root,
104- Worktree: MemWorktree(filepath.Join(root, "wt")),
105 })102 })
106 loaded, errs := second.Reload()103 loaded, errs := second.Reload()
107 if len(errs) > 0 {104 if len(errs) > 0 {
@@ -140,7 +137,6 @@ func TestReloadIsIdempotent(t *testing.T) {
140 root := t.TempDir()137 root := t.TempDir()
141 first := New(Config{138 first := New(Config{
142 Root: root, RepoRoot: root,139 Root: root, RepoRoot: root,
143- Worktree: MemWorktree(filepath.Join(root, "wt")),
144 NewRunner: func(a *Agent) Runner {140 NewRunner: func(a *Agent) Runner {
145 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })141 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
146 },142 },
@@ -150,7 +146,7 @@ func TestReloadIsIdempotent(t *testing.T) {
150 }146 }
151 first.StopAll()147 first.StopAll()
152 148
153- second := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})149+ second := New(Config{Root: root, RepoRoot: root})
154 loaded1, _ := second.Reload()150 loaded1, _ := second.Reload()
155 loaded2, errs := second.Reload()151 loaded2, errs := second.Reload()
156 if len(errs) > 0 {152 if len(errs) > 0 {
@@ -177,8 +173,8 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
177 }173 }
178 // meta.json174 // meta.json
179 m := agentMeta{175 m := agentMeta{
180- ID: id, Task: "do thing", Branch: "swarm/alpha-9",176+ ID: id, Task: "do thing",
181- Dir: filepath.Join(root, "wt", id), Started: time.Now().Add(-time.Hour),177+ Dir: root, Started: time.Now().Add(-time.Hour),
182 InboxPath: filepath.Join(stateDir, "in.sock"),178 InboxPath: filepath.Join(stateDir, "in.sock"),
183 EventLogPath: filepath.Join(stateDir, "events.jsonl"),179 EventLogPath: filepath.Join(stateDir, "events.jsonl"),
184 SessionPath: filepath.Join(stateDir, "session.json"),180 SessionPath: filepath.Join(stateDir, "session.json"),
@@ -198,7 +194,7 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
198 _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "shutdown"}))194 _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "shutdown"}))
199 _ = log.Close()195 _ = log.Close()
200 196
201- f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})197+ f := New(Config{Root: root, RepoRoot: root})
202 loaded, errs := f.Reload()198 loaded, errs := f.Reload()
203 if len(errs) > 0 || loaded != 1 {199 if len(errs) > 0 || loaded != 1 {
204 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)200 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
@@ -246,11 +242,11 @@ func TestReloadSkipsBareDirsAndCorruptMeta(t *testing.T) {
246 good := "good-1"242 good := "good-1"
247 stateDir := filepath.Join(agentsDir, good)243 stateDir := filepath.Join(agentsDir, good)
248 _ = os.MkdirAll(stateDir, 0o755)244 _ = os.MkdirAll(stateDir, 0o755)
249- m := agentMeta{ID: good, Task: "x", Branch: "swarm/" + good, Dir: "/tmp/x", Started: time.Now()}245+ m := agentMeta{ID: good, Task: "x", Dir: "/tmp/x", Started: time.Now()}
250 mb, _ := json.MarshalIndent(m, "", " ")246 mb, _ := json.MarshalIndent(m, "", " ")
251 _ = os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644)247 _ = os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644)
252 248
253- f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})249+ f := New(Config{Root: root, RepoRoot: root})
254 loaded, errs := f.Reload()250 loaded, errs := f.Reload()
255 if loaded != 1 {251 if loaded != 1 {
256 t.Errorf("loaded = %d; want 1", loaded)252 t.Errorf("loaded = %d; want 1", loaded)
@@ -276,7 +272,6 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
276 )272 )
277 f := New(Config{273 f := New(Config{
278 Root: root, RepoRoot: root,274 Root: root, RepoRoot: root,
279- Worktree: MemWorktree(filepath.Join(root, "wt")),
280 NewRunner: func(a *Agent) Runner {275 NewRunner: func(a *Agent) Runner {
281 return RunnerFunc(func(ctx context.Context, sink Sink) error {276 return RunnerFunc(func(ctx context.Context, sink Sink) error {
282 mu.Lock()277 mu.Lock()
@@ -358,7 +353,6 @@ func TestResumeSetsResumingFlag(t *testing.T) {
358 root := t.TempDir()353 root := t.TempDir()
359 f := New(Config{354 f := New(Config{
360 Root: root, RepoRoot: root,355 Root: root, RepoRoot: root,
361- Worktree: MemWorktree(filepath.Join(root, "wt")),
362 NewRunner: func(a *Agent) Runner {356 NewRunner: func(a *Agent) Runner {
363 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })357 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
364 },358 },
@@ -392,7 +386,6 @@ func TestResumeRejectsRunningAgent(t *testing.T) {
392 root := t.TempDir()386 root := t.TempDir()
393 f := New(Config{387 f := New(Config{
394 Root: root, RepoRoot: root,388 Root: root, RepoRoot: root,
395- Worktree: MemWorktree(filepath.Join(root, "wt")),
396 NewRunner: func(a *Agent) Runner {389 NewRunner: func(a *Agent) Runner {
397 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })390 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
398 },391 },
@@ -430,7 +423,6 @@ func TestResumeAfterReload(t *testing.T) {
430 // Process A423 // Process A
431 a := New(Config{424 a := New(Config{
432 Root: root, RepoRoot: root,425 Root: root, RepoRoot: root,
433- Worktree: MemWorktree(filepath.Join(root, "wt")),
434 NewRunner: func(ag *Agent) Runner {426 NewRunner: func(ag *Agent) Runner {
435 return RunnerFunc(func(ctx context.Context, sink Sink) error {427 return RunnerFunc(func(ctx context.Context, sink Sink) error {
436 sink.Transcript("first run for " + ag.ID)428 sink.Transcript("first run for " + ag.ID)
@@ -456,7 +448,6 @@ func TestResumeAfterReload(t *testing.T) {
456 resumed := make(chan struct{}, 1)448 resumed := make(chan struct{}, 1)
457 b := New(Config{449 b := New(Config{
458 Root: root, RepoRoot: root,450 Root: root, RepoRoot: root,
459- Worktree: MemWorktree(filepath.Join(root, "wt")),
460 NewRunner: func(ag *Agent) Runner {451 NewRunner: func(ag *Agent) Runner {
461 return RunnerFunc(func(ctx context.Context, sink Sink) error {452 return RunnerFunc(func(ctx context.Context, sink Sink) error {
462 sink.Transcript("second run for " + ag.ID)453 sink.Transcript("second run for " + ag.ID)
@@ -507,7 +498,6 @@ func TestSpawnReqPersistsModel(t *testing.T) {
507 root := t.TempDir()498 root := t.TempDir()
508 f := New(Config{499 f := New(Config{
509 Root: root, RepoRoot: root,500 Root: root, RepoRoot: root,
510- Worktree: MemWorktree(filepath.Join(root, "wt")),
511 NewRunner: func(a *Agent) Runner {501 NewRunner: func(a *Agent) Runner {
512 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })502 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
513 },503 },
@@ -547,7 +537,7 @@ func TestSpawnReqPersistsModel(t *testing.T) {
547 // Reload in a fresh Swarm and confirm the detached agent still537 // Reload in a fresh Swarm and confirm the detached agent still
548 // carries the model/provider so Resume can route the child538 // carries the model/provider so Resume can route the child
549 // subprocess back to the same model.539 // subprocess back to the same model.
550- g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})540+ g := New(Config{Root: root, RepoRoot: root})
551 if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {541 if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {
552 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)542 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
553 }543 }
@@ -605,8 +595,8 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) {
605 t.Fatal(err)595 t.Fatal(err)
606 }596 }
607 m := agentMeta{597 m := agentMeta{
608- ID: id, Task: "t", Branch: "swarm/" + id,598+ ID: id, Task: "t",
609- Dir: filepath.Join(root, "wt", id),599+ Dir: root,
610 Started: time.Now().Add(-time.Hour),600 Started: time.Now().Add(-time.Hour),
611 InboxPath: filepath.Join(stateDir, "in.sock"),601 InboxPath: filepath.Join(stateDir, "in.sock"),
612 EventLogPath: filepath.Join(stateDir, "events.jsonl"),602 EventLogPath: filepath.Join(stateDir, "events.jsonl"),
@@ -617,7 +607,7 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) {
617 t.Fatal(err)607 t.Fatal(err)
618 }608 }
619 609
620- f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})610+ f := New(Config{Root: root, RepoRoot: root})
621 if loaded, errs := f.Reload(); loaded != 1 || len(errs) > 0 {611 if loaded, errs := f.Reload(); loaded != 1 || len(errs) > 0 {
622 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)612 t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
623 }613 }
@@ -654,7 +644,6 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) {
654 root := t.TempDir()644 root := t.TempDir()
655 f := New(Config{645 f := New(Config{
656 Root: root, RepoRoot: root,646 Root: root, RepoRoot: root,
657- Worktree: MemWorktree(filepath.Join(root, "wt")),
658 NewRunner: func(a *Agent) Runner {647 NewRunner: func(a *Agent) Runner {
659 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })648 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
660 },649 },
@@ -679,7 +668,7 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) {
679 }668 }
680 669
681 // A fresh Swarm + Reload should find nothing.670 // A fresh Swarm + Reload should find nothing.
682- g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})671+ g := New(Config{Root: root, RepoRoot: root})
683 if loaded, _ := g.Reload(); loaded != 0 {672 if loaded, _ := g.Reload(); loaded != 0 {
684 t.Fatalf("reload after remove loaded=%d; want 0", loaded)673 t.Fatalf("reload after remove loaded=%d; want 0", loaded)
685 }674 }
@@ -694,7 +683,6 @@ func TestActiveSessionScopesSnapshotAll(t *testing.T) {
694 root := t.TempDir()683 root := t.TempDir()
695 f := New(Config{684 f := New(Config{
696 Root: root, RepoRoot: root,685 Root: root, RepoRoot: root,
697- Worktree: MemWorktree(filepath.Join(root, "wt")),
698 NewRunner: func(a *Agent) Runner {686 NewRunner: func(a *Agent) Runner {
699 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })687 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
700 },688 },
@@ -759,7 +747,6 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) {
759 mkSwarm := func() *Swarm {747 mkSwarm := func() *Swarm {
760 return New(Config{748 return New(Config{
761 Root: root, RepoRoot: root,749 Root: root, RepoRoot: root,
762- Worktree: MemWorktree(filepath.Join(root, "wt")),
763 NewRunner: func(a *Agent) Runner {750 NewRunner: func(a *Agent) Runner {
764 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })751 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
765 },752 },
@@ -803,7 +790,6 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) {
803 root := t.TempDir()790 root := t.TempDir()
804 f := New(Config{791 f := New(Config{
805 Root: root, RepoRoot: root,792 Root: root, RepoRoot: root,
806- Worktree: MemWorktree(filepath.Join(root, "wt")),
807 NewRunner: func(a *Agent) Runner {793 NewRunner: func(a *Agent) Runner {
808 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })794 return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
809 },795 },
M internal/swarm/runner.go +29 -5
@@ -14,7 +14,8 @@ import (
1414 )
1515
1616 // execRunner spawns `zot --swarm-agent <inbox> --session <path>` in
17-// the agent's worktree and consumes its JSONL event stream on stdout.
17+// the host's working directory (Agent.Dir, which is always the parent
18+// zot's RepoRoot) and consumes its JSONL event stream on stdout.
1819 //
1920 // Why a long-lived daemon and not `zot --print`: the supervisor and
2021 // the user expect agents to keep accepting follow-up prompts. A
@@ -40,9 +41,14 @@ type execRunner struct {
4041 // tested without a real child. Production code leaves it nil.
4142 Command []string
4243
43- // SessionPath is the agent's session file. When empty the
44- // runner derives it as <Dir>/.zot/session.json so each agent
45- // owns its own session inside its worktree.
44+ // SessionPath is the agent's session file. Empty means "defer
45+ // to r.agent.SessionPath", which Swarm.Spawn always populates
46+ // with <swarm-root>/agents/<id>/session.json. Tests that
47+ // hand-build an Agent without going through Spawn must set
48+ // one of the two; the runner refuses to invent a fallback
49+ // because the only plausible one (<Dir>/.zot/session.json)
50+ // would litter the user's repo — every agent's Dir points
51+ // at it directly.
4652 SessionPath string
4753 }
4854
@@ -116,9 +122,27 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string {
116122 }
117123
118124 func (r *execRunner) Run(ctx context.Context, sink Sink) error {
125+ // SessionPath resolution order:
126+ // 1. explicit r.SessionPath set by the test / caller
127+ // 2. r.agent.SessionPath baked in by Swarm.Spawn — the
128+ // production path. Always lives under
129+ // <swarm-root>/agents/<id>/session.json so the per-
130+ // agent state is entirely outside the working tree.
131+ // Crucial because Agent.Dir points at the user's repo;
132+ // any .zot/ scratch directory under Dir would litter
133+ // their source tree.
134+ //
135+ // There is no third fallback. If neither path is set we
136+ // refuse to start instead of inventing a directory; that
137+ // way a misconfigured caller fails loudly the first time
138+ // instead of silently dumping session data into someone's
139+ // repo.
119140 sessionPath := r.SessionPath
120141 if sessionPath == "" {
121- sessionPath = filepath.Join(r.agent.Dir, ".zot", "session.json")
142+ sessionPath = r.agent.SessionPath
143+ }
144+ if sessionPath == "" {
145+ return fmt.Errorf("swarm: agent missing session path (set SpawnRequest via Swarm.SpawnReq, or hand-build Agent with SessionPath populated)")
122146 }
123147 if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
124148 return fmt.Errorf("session dir: %w", err)
@@ -14,7 +14,8 @@ import (
14 )14 )
15 15
16 // execRunner spawns `zot --swarm-agent <inbox> --session <path>` in16 // execRunner spawns `zot --swarm-agent <inbox> --session <path>` in
17-// the agent's worktree and consumes its JSONL event stream on stdout.17+// the host's working directory (Agent.Dir, which is always the parent
18+// zot's RepoRoot) and consumes its JSONL event stream on stdout.
18 //19 //
19 // Why a long-lived daemon and not `zot --print`: the supervisor and20 // Why a long-lived daemon and not `zot --print`: the supervisor and
20 // the user expect agents to keep accepting follow-up prompts. A21 // the user expect agents to keep accepting follow-up prompts. A
@@ -40,9 +41,14 @@ type execRunner struct {
40 // tested without a real child. Production code leaves it nil.41 // tested without a real child. Production code leaves it nil.
41 Command []string42 Command []string
42 43
43- // SessionPath is the agent's session file. When empty the44+ // SessionPath is the agent's session file. Empty means "defer
44- // runner derives it as <Dir>/.zot/session.json so each agent45+ // to r.agent.SessionPath", which Swarm.Spawn always populates
45- // owns its own session inside its worktree.46+ // with <swarm-root>/agents/<id>/session.json. Tests that
47+ // hand-build an Agent without going through Spawn must set
48+ // one of the two; the runner refuses to invent a fallback
49+ // because the only plausible one (<Dir>/.zot/session.json)
50+ // would litter the user's repo — every agent's Dir points
51+ // at it directly.
46 SessionPath string52 SessionPath string
47 }53 }
48 54
@@ -116,9 +122,27 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string {
116 }122 }
117 123
118 func (r *execRunner) Run(ctx context.Context, sink Sink) error {124 func (r *execRunner) Run(ctx context.Context, sink Sink) error {
125+ // SessionPath resolution order:
126+ // 1. explicit r.SessionPath set by the test / caller
127+ // 2. r.agent.SessionPath baked in by Swarm.Spawn — the
128+ // production path. Always lives under
129+ // <swarm-root>/agents/<id>/session.json so the per-
130+ // agent state is entirely outside the working tree.
131+ // Crucial because Agent.Dir points at the user's repo;
132+ // any .zot/ scratch directory under Dir would litter
133+ // their source tree.
134+ //
135+ // There is no third fallback. If neither path is set we
136+ // refuse to start instead of inventing a directory; that
137+ // way a misconfigured caller fails loudly the first time
138+ // instead of silently dumping session data into someone's
139+ // repo.
119 sessionPath := r.SessionPath140 sessionPath := r.SessionPath
120 if sessionPath == "" {141 if sessionPath == "" {
121- sessionPath = filepath.Join(r.agent.Dir, ".zot", "session.json")142+ sessionPath = r.agent.SessionPath
143+ }
144+ if sessionPath == "" {
145+ return fmt.Errorf("swarm: agent missing session path (set SpawnRequest via Swarm.SpawnReq, or hand-build Agent with SessionPath populated)")
122 }146 }
123 if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {147 if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
124 return fmt.Errorf("session dir: %w", err)148 return fmt.Errorf("session dir: %w", err)
M internal/swarm/runner_e2e_test.go +0 -1
@@ -46,7 +46,6 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) {
4646 f := New(Config{
4747 Root: root,
4848 RepoRoot: repo,
49- Worktree: MemWorktree(filepath.Join(root, "wt")),
5049 NewRunner: func(a *Agent) Runner {
5150 return &execRunner{
5251 agent: a,
@@ -46,7 +46,6 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) {
46 f := New(Config{46 f := New(Config{
47 Root: root,47 Root: root,
48 RepoRoot: repo,48 RepoRoot: repo,
49- Worktree: MemWorktree(filepath.Join(root, "wt")),
50 NewRunner: func(a *Agent) Runner {49 NewRunner: func(a *Agent) Runner {
51 return &execRunner{50 return &execRunner{
52 agent: a,51 agent: a,
M internal/swarm/swarm.go +45 -52
@@ -1,20 +1,26 @@
11 // Package swarm implements zot's multi-agent supervisor.
22 //
33 // A Swarm manages a set of headless zot subprocesses ("agents")
4-// working in their own git worktrees. The interactive TUI exposes
5-// the supervisor through the /swarm slash command and a dashboard
6-// dialog; non-TUI code can drive it directly through this package.
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.
714 //
815 // Each Agent has:
916 // - a unique id (short slug + nanoseconds)
10-// - a branch name on a fresh git worktree under <root>/swarm/<id>
1117 // - a Runner (the thing that actually executes the task)
1218 // - a Status string + Activity string that the dashboard reads
1319 //
1420 // The Runner abstraction means tests can swap a fake in instead of
1521 // really spawning a subprocess; the production Runner shells out to
16-// `zot --print` so we reuse zot's own model resolution and tooling
17-// without re-implementing the agent loop.
22+// `zot --swarm-agent ...` so we reuse zot's own model resolution
23+// and tooling without re-implementing the agent loop.
1824 package swarm
1925
2026 import (
@@ -43,32 +49,24 @@ const (
4349
4450 // Config configures a Swarm.
4551 type Config struct {
46- // Root is the directory under which worktrees + state files live.
52+ // Root is the directory under which per-agent state files live.
4753 // Typically <ZotHome>/swarm, but tests pass a tempdir.
4854 Root string
4955
50- // RepoRoot is the path to the user's git repo (CWD of the parent
51- // zot). New worktrees branch off this repo.
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.
5259 RepoRoot string
5360
54- // Worktree creates the per-agent working directory. If nil, the
55- // default git-worktree implementation is used.
56- Worktree WorktreeManager
57-
5861 // NewRunner produces the Runner for an Agent. If nil, the default
59- // `zot --print` exec runner is used. Tests inject a fake here.
62+ // `zot --swarm-agent ...` exec runner is used. Tests inject a fake
63+ // here.
6064 NewRunner func(a *Agent) Runner
6165
6266 // Now is a clock seam for tests; defaults to time.Now.
6367 Now func() time.Time
6468 }
6569
66-// WorktreeManager creates and removes per-agent working directories.
67-type WorktreeManager interface {
68- Create(id, branch, base string) (dir string, err error)
69- Remove(id, dir string) error
70-}
71-
7270 // Runner executes one agent task. Run blocks until the task finishes,
7371 // is cancelled via ctx, or hits an unrecoverable error.
7472 //
@@ -118,9 +116,6 @@ func New(cfg Config) *Swarm {
118116 if cfg.Now == nil {
119117 cfg.Now = time.Now
120118 }
121- if cfg.Worktree == nil {
122- cfg.Worktree = &gitWorktree{root: filepath.Join(cfg.Root, "worktrees"), repo: cfg.RepoRoot}
123- }
124119 if cfg.NewRunner == nil {
125120 cfg.NewRunner = func(a *Agent) Runner { return &execRunner{agent: a} }
126121 }
@@ -158,7 +153,7 @@ func (f *Swarm) ActiveSession() string {
158153 // events.jsonl durable event log (runner-owned)
159154 // in.sock unix socket inbox (child-owned)
160155 // session.json persistent agent session (child-owned)
161-// meta.json static metadata (id, task, branch, dir)
156+// meta.json static metadata (id, task)
162157 func (f *Swarm) agentStateDir(id string) string {
163158 return filepath.Join(f.cfg.Root, "agents", id)
164159 }
@@ -173,13 +168,13 @@ type SpawnRequest struct {
173168 Provider string // optional override; usually paired with Model
174169 }
175170
176-// Spawn creates a new Agent for the given task, sets up its worktree,
177-// allocates the on-disk state directory (events log, inbox socket
178-// path, session file path), and starts the Runner on a background
179-// goroutine. The returned Agent is already in StatusRunning (or
180-// StatusFailed if worktree setup failed before the goroutine
181-// started). This is the historical signature; callers that want to
182-// override the child's model use SpawnReq instead.
171+// Spawn creates a new Agent for the given task, allocates its
172+// on-disk state directory (events log, inbox socket path, session
173+// file path), and starts the Runner on a background goroutine. The
174+// returned Agent is already in StatusRunning (or StatusFailed if
175+// state setup failed before the goroutine started). This is the
176+// historical signature; callers that want to override the child's
177+// model use SpawnReq instead.
183178 func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) {
184179 return f.SpawnReq(ctx, SpawnRequest{Task: task})
185180 }
@@ -187,17 +182,17 @@ func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) {
187182 // SpawnReq is the full-fat variant of Spawn that accepts a
188183 // SpawnRequest. Existing callers can keep using Spawn; new code that
189184 // wants to pin the child's model uses this.
185+//
186+// Every spawned agent runs with cwd == cfg.RepoRoot — the same
187+// working directory as the host. No per-agent worktree, no branch,
188+// no isolation. The user explicitly opted out of the worktree flow.
190189 func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) {
191190 task := strings.TrimSpace(req.Task)
192191 if task == "" {
193192 return nil, errors.New("swarm: empty task")
194193 }
195194 id := newAgentID(task, f.cfg.Now())
196- branch := "swarm/" + id
197- dir, err := f.cfg.Worktree.Create(id, branch, f.cfg.RepoRoot)
198- if err != nil {
199- return nil, fmt.Errorf("worktree create: %w", err)
200- }
195+ dir := f.cfg.RepoRoot
201196
202197 stateDir := f.agentStateDir(id)
203198 if err := os.MkdirAll(stateDir, 0o755); err != nil {
@@ -226,7 +221,6 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
226221 a := &Agent{
227222 ID: id,
228223 Task: task,
229- Branch: branch,
230224 Dir: dir,
231225 Started: f.cfg.Now(),
232226 Model: strings.TrimSpace(req.Model),
@@ -385,11 +379,15 @@ func (f *Swarm) StopAll() {
385379 }
386380 }
387381
388-// Remove tears down the worktree for a terminated agent. It is an
389-// error to remove an agent that's still running; call Stop first and
390-// wait for the status to settle. Detached agents (reloaded from
391-// disk) remove cleanly because they have no live runner racing for
392-// the same files.
382+// Remove tears down the per-agent state for a terminated agent. It
383+// is an error to remove an agent that's still running; call Stop
384+// first and wait for the status to settle. Detached agents
385+// (reloaded from disk) remove cleanly because they have no live
386+// runner racing for the same files.
387+//
388+// Agents share the host's working tree, so Remove never touches
389+// any source file — it only deletes the agent's state directory
390+// under <root>/agents/<id>/.
393391 func (f *Swarm) Remove(id string) error {
394392 a := f.Get(id)
395393 if a == nil {
@@ -397,18 +395,14 @@ func (f *Swarm) Remove(id string) error {
397395 }
398396 a.mu.Lock()
399397 st := a.status
400- dir := a.Dir
401398 a.mu.Unlock()
402399 if st == StatusRunning || st == StatusPending {
403400 return fmt.Errorf("agent %s still %s", a.ID, st)
404401 }
405- if err := f.cfg.Worktree.Remove(a.ID, dir); err != nil {
406- return err
407- }
408- // Best-effort cleanup of the per-agent state directory (meta.json,
409- // events.jsonl, session.json, in.sock if it's local). Worktree
410- // removal already succeeded; failing here would leave the user
411- // with no recourse, so swallow the error.
402+ // Best-effort cleanup of the per-agent state directory
403+ // (meta.json, events.jsonl, session.json, in.sock if it's
404+ // local). Failing here would leave the user with no recourse,
405+ // so swallow the error.
412406 _ = os.RemoveAll(f.agentStateDir(a.ID))
413407 f.mu.Lock()
414408 delete(f.agents, a.ID)
@@ -427,7 +421,6 @@ func (f *Swarm) Remove(id string) error {
427421 type AgentSnapshot struct {
428422 ID string
429423 Task string
430- Branch string
431424 Dir string
432425 Status Status
433426 Activity string
@@ -465,7 +458,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
465458 errStr = a.lastErr.Error()
466459 }
467460 return AgentSnapshot{
468- ID: a.ID, Task: a.Task, Branch: a.Branch, Dir: a.Dir,
461+ ID: a.ID, Task: a.Task, Dir: a.Dir,
469462 Status: a.status, Activity: a.activity,
470463 Started: a.Started, Finished: a.finished,
471464 Err: errStr, Tail: tail, Lines: lines,
@@ -1,20 +1,26 @@
1 // Package swarm implements zot's multi-agent supervisor.1 // Package swarm implements zot's multi-agent supervisor.
2 //2 //
3 // A Swarm manages a set of headless zot subprocesses ("agents")3 // A Swarm manages a set of headless zot subprocesses ("agents")
4-// working in their own git worktrees. The interactive TUI exposes4+// that share the host's working directory. The interactive TUI
5-// the supervisor through the /swarm slash command and a dashboard5+// exposes the supervisor through the /swarm slash command and a
6-// dialog; non-TUI code can drive it directly through this package.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.
7 //14 //
8 // Each Agent has:15 // Each Agent has:
9 // - a unique id (short slug + nanoseconds)16 // - a unique id (short slug + nanoseconds)
10-// - a branch name on a fresh git worktree under <root>/swarm/<id>
11 // - a Runner (the thing that actually executes the task)17 // - a Runner (the thing that actually executes the task)
12 // - a Status string + Activity string that the dashboard reads18 // - a Status string + Activity string that the dashboard reads
13 //19 //
14 // The Runner abstraction means tests can swap a fake in instead of20 // The Runner abstraction means tests can swap a fake in instead of
15 // really spawning a subprocess; the production Runner shells out to21 // really spawning a subprocess; the production Runner shells out to
16-// `zot --print` so we reuse zot's own model resolution and tooling22+// `zot --swarm-agent ...` so we reuse zot's own model resolution
17-// without re-implementing the agent loop.23+// and tooling without re-implementing the agent loop.
18 package swarm24 package swarm
19 25
20 import (26 import (
@@ -43,32 +49,24 @@ const (
43 49
44 // Config configures a Swarm.50 // Config configures a Swarm.
45 type Config struct {51 type Config struct {
46- // Root is the directory under which worktrees + state files live.52+ // Root is the directory under which per-agent state files live.
47 // Typically <ZotHome>/swarm, but tests pass a tempdir.53 // Typically <ZotHome>/swarm, but tests pass a tempdir.
48 Root string54 Root string
49 55
50- // RepoRoot is the path to the user's git repo (CWD of the parent56+ // RepoRoot is the working directory every spawned agent runs
51- // zot). New worktrees branch off this repo.57+ // in — the same cwd the parent zot is using. There is no
58+ // per-agent isolation: agents edit the host's files directly.
52 RepoRoot string59 RepoRoot string
53 60
54- // Worktree creates the per-agent working directory. If nil, the
55- // default git-worktree implementation is used.
56- Worktree WorktreeManager
57-
58 // NewRunner produces the Runner for an Agent. If nil, the default61 // NewRunner produces the Runner for an Agent. If nil, the default
59- // `zot --print` exec runner is used. Tests inject a fake here.62+ // `zot --swarm-agent ...` exec runner is used. Tests inject a fake
63+ // here.
60 NewRunner func(a *Agent) Runner64 NewRunner func(a *Agent) Runner
61 65
62 // Now is a clock seam for tests; defaults to time.Now.66 // Now is a clock seam for tests; defaults to time.Now.
63 Now func() time.Time67 Now func() time.Time
64 }68 }
65 69
66-// WorktreeManager creates and removes per-agent working directories.
67-type WorktreeManager interface {
68- Create(id, branch, base string) (dir string, err error)
69- Remove(id, dir string) error
70-}
71-
72 // Runner executes one agent task. Run blocks until the task finishes,70 // Runner executes one agent task. Run blocks until the task finishes,
73 // is cancelled via ctx, or hits an unrecoverable error.71 // is cancelled via ctx, or hits an unrecoverable error.
74 //72 //
@@ -118,9 +116,6 @@ func New(cfg Config) *Swarm {
118 if cfg.Now == nil {116 if cfg.Now == nil {
119 cfg.Now = time.Now117 cfg.Now = time.Now
120 }118 }
121- if cfg.Worktree == nil {
122- cfg.Worktree = &gitWorktree{root: filepath.Join(cfg.Root, "worktrees"), repo: cfg.RepoRoot}
123- }
124 if cfg.NewRunner == nil {119 if cfg.NewRunner == nil {
125 cfg.NewRunner = func(a *Agent) Runner { return &execRunner{agent: a} }120 cfg.NewRunner = func(a *Agent) Runner { return &execRunner{agent: a} }
126 }121 }
@@ -158,7 +153,7 @@ func (f *Swarm) ActiveSession() string {
158 // events.jsonl durable event log (runner-owned)153 // events.jsonl durable event log (runner-owned)
159 // in.sock unix socket inbox (child-owned)154 // in.sock unix socket inbox (child-owned)
160 // session.json persistent agent session (child-owned)155 // session.json persistent agent session (child-owned)
161-// meta.json static metadata (id, task, branch, dir)156+// meta.json static metadata (id, task)
162 func (f *Swarm) agentStateDir(id string) string {157 func (f *Swarm) agentStateDir(id string) string {
163 return filepath.Join(f.cfg.Root, "agents", id)158 return filepath.Join(f.cfg.Root, "agents", id)
164 }159 }
@@ -173,13 +168,13 @@ type SpawnRequest struct {
173 Provider string // optional override; usually paired with Model168 Provider string // optional override; usually paired with Model
174 }169 }
175 170
176-// Spawn creates a new Agent for the given task, sets up its worktree,171+// Spawn creates a new Agent for the given task, allocates its
177-// allocates the on-disk state directory (events log, inbox socket172+// on-disk state directory (events log, inbox socket path, session
178-// path, session file path), and starts the Runner on a background173+// file path), and starts the Runner on a background goroutine. The
179-// goroutine. The returned Agent is already in StatusRunning (or174+// returned Agent is already in StatusRunning (or StatusFailed if
180-// StatusFailed if worktree setup failed before the goroutine175+// state setup failed before the goroutine started). This is the
181-// started). This is the historical signature; callers that want to176+// historical signature; callers that want to override the child's
182-// override the child's model use SpawnReq instead.177+// model use SpawnReq instead.
183 func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) {178 func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) {
184 return f.SpawnReq(ctx, SpawnRequest{Task: task})179 return f.SpawnReq(ctx, SpawnRequest{Task: task})
185 }180 }
@@ -187,17 +182,17 @@ func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) {
187 // SpawnReq is the full-fat variant of Spawn that accepts a182 // SpawnReq is the full-fat variant of Spawn that accepts a
188 // SpawnRequest. Existing callers can keep using Spawn; new code that183 // SpawnRequest. Existing callers can keep using Spawn; new code that
189 // wants to pin the child's model uses this.184 // wants to pin the child's model uses this.
185+//
186+// Every spawned agent runs with cwd == cfg.RepoRoot — the same
187+// working directory as the host. No per-agent worktree, no branch,
188+// no isolation. The user explicitly opted out of the worktree flow.
190 func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) {189 func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) {
191 task := strings.TrimSpace(req.Task)190 task := strings.TrimSpace(req.Task)
192 if task == "" {191 if task == "" {
193 return nil, errors.New("swarm: empty task")192 return nil, errors.New("swarm: empty task")
194 }193 }
195 id := newAgentID(task, f.cfg.Now())194 id := newAgentID(task, f.cfg.Now())
196- branch := "swarm/" + id195+ dir := f.cfg.RepoRoot
197- dir, err := f.cfg.Worktree.Create(id, branch, f.cfg.RepoRoot)
198- if err != nil {
199- return nil, fmt.Errorf("worktree create: %w", err)
200- }
201 196
202 stateDir := f.agentStateDir(id)197 stateDir := f.agentStateDir(id)
203 if err := os.MkdirAll(stateDir, 0o755); err != nil {198 if err := os.MkdirAll(stateDir, 0o755); err != nil {
@@ -226,7 +221,6 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
226 a := &Agent{221 a := &Agent{
227 ID: id,222 ID: id,
228 Task: task,223 Task: task,
229- Branch: branch,
230 Dir: dir,224 Dir: dir,
231 Started: f.cfg.Now(),225 Started: f.cfg.Now(),
232 Model: strings.TrimSpace(req.Model),226 Model: strings.TrimSpace(req.Model),
@@ -385,11 +379,15 @@ func (f *Swarm) StopAll() {
385 }379 }
386 }380 }
387 381
388-// Remove tears down the worktree for a terminated agent. It is an382+// Remove tears down the per-agent state for a terminated agent. It
389-// error to remove an agent that's still running; call Stop first and383+// is an error to remove an agent that's still running; call Stop
390-// wait for the status to settle. Detached agents (reloaded from384+// first and wait for the status to settle. Detached agents
391-// disk) remove cleanly because they have no live runner racing for385+// (reloaded from disk) remove cleanly because they have no live
392-// the same files.386+// runner racing for the same files.
387+//
388+// Agents share the host's working tree, so Remove never touches
389+// any source file — it only deletes the agent's state directory
390+// under <root>/agents/<id>/.
393 func (f *Swarm) Remove(id string) error {391 func (f *Swarm) Remove(id string) error {
394 a := f.Get(id)392 a := f.Get(id)
395 if a == nil {393 if a == nil {
@@ -397,18 +395,14 @@ func (f *Swarm) Remove(id string) error {
397 }395 }
398 a.mu.Lock()396 a.mu.Lock()
399 st := a.status397 st := a.status
400- dir := a.Dir
401 a.mu.Unlock()398 a.mu.Unlock()
402 if st == StatusRunning || st == StatusPending {399 if st == StatusRunning || st == StatusPending {
403 return fmt.Errorf("agent %s still %s", a.ID, st)400 return fmt.Errorf("agent %s still %s", a.ID, st)
404 }401 }
405- if err := f.cfg.Worktree.Remove(a.ID, dir); err != nil {402+ // Best-effort cleanup of the per-agent state directory
406- return err403+ // (meta.json, events.jsonl, session.json, in.sock if it's
407- }404+ // local). Failing here would leave the user with no recourse,
408- // Best-effort cleanup of the per-agent state directory (meta.json,405+ // so swallow the error.
409- // events.jsonl, session.json, in.sock if it's local). Worktree
410- // removal already succeeded; failing here would leave the user
411- // with no recourse, so swallow the error.
412 _ = os.RemoveAll(f.agentStateDir(a.ID))406 _ = os.RemoveAll(f.agentStateDir(a.ID))
413 f.mu.Lock()407 f.mu.Lock()
414 delete(f.agents, a.ID)408 delete(f.agents, a.ID)
@@ -427,7 +421,6 @@ func (f *Swarm) Remove(id string) error {
427 type AgentSnapshot struct {421 type AgentSnapshot struct {
428 ID string422 ID string
429 Task string423 Task string
430- Branch string
431 Dir string424 Dir string
432 Status Status425 Status Status
433 Activity string426 Activity string
@@ -465,7 +458,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
465 errStr = a.lastErr.Error()458 errStr = a.lastErr.Error()
466 }459 }
467 return AgentSnapshot{460 return AgentSnapshot{
468- ID: a.ID, Task: a.Task, Branch: a.Branch, Dir: a.Dir,461+ ID: a.ID, Task: a.Task, Dir: a.Dir,
469 Status: a.status, Activity: a.activity,462 Status: a.status, Activity: a.activity,
470 Started: a.Started, Finished: a.finished,463 Started: a.Started, Finished: a.finished,
471 Err: errStr, Tail: tail, Lines: lines,464 Err: errStr, Tail: tail, Lines: lines,
M internal/swarm/swarm_test.go +22 -7
@@ -3,7 +3,6 @@ package swarm
33 import (
44 "context"
55 "errors"
6- "path/filepath"
76 "strings"
87 "sync"
98 "testing"
@@ -11,16 +10,15 @@ import (
1110 )
1211
1312 // newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory
14-// worktree and a Runner factory the test controls. Returns the swarm
15-// plus a slice of runners keyed by spawn order so tests can assert
16-// they were actually invoked.
13+// and a Runner factory the test controls. Returns the swarm plus a
14+// slice of runners keyed by spawn order so tests can assert they
15+// were actually invoked.
1716 func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm {
1817 t.Helper()
1918 root := t.TempDir()
2019 return New(Config{
2120 Root: root,
2221 RepoRoot: root,
23- Worktree: MemWorktree(filepath.Join(root, "worktrees")),
2422 NewRunner: mk,
2523 })
2624 }
@@ -58,8 +56,25 @@ func TestSpawnRunsAndCompletes(t *testing.T) {
5856 if !strings.Contains(a.ID, "do-a-thing") {
5957 t.Fatalf("id %q missing slug", a.ID)
6058 }
61- if a.Branch != "swarm/"+a.ID {
62- t.Fatalf("branch = %q", a.Branch)
59+ // Every agent shares the host's RepoRoot.
60+ if a.Dir != f.cfg.RepoRoot {
61+ t.Fatalf("dir = %q; want repo root %q", a.Dir, f.cfg.RepoRoot)
62+ }
63+}
64+
65+// TestSpawnAgentSharesRepoRoot verifies the only-mode-we-support:
66+// every spawned agent points its cwd at the parent zot's RepoRoot.
67+func TestSpawnAgentSharesRepoRoot(t *testing.T) {
68+ f := newTestSwarm(t, func(a *Agent) Runner {
69+ return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
70+ })
71+ a, err := f.Spawn(context.Background(), "share me")
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ a.Wait()
76+ if a.Dir != f.cfg.RepoRoot {
77+ t.Fatalf("Dir = %q; want RepoRoot %q", a.Dir, f.cfg.RepoRoot)
6378 }
6479 }
6580
@@ -3,7 +3,6 @@ package swarm
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6- "path/filepath"
7 "strings"6 "strings"
8 "sync"7 "sync"
9 "testing"8 "testing"
@@ -11,16 +10,15 @@ import (
11 )10 )
12 11
13 // newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory12 // newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory
14-// worktree and a Runner factory the test controls. Returns the swarm13+// and a Runner factory the test controls. Returns the swarm plus a
15-// plus a slice of runners keyed by spawn order so tests can assert14+// slice of runners keyed by spawn order so tests can assert they
16-// they were actually invoked.15+// were actually invoked.
17 func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm {16 func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm {
18 t.Helper()17 t.Helper()
19 root := t.TempDir()18 root := t.TempDir()
20 return New(Config{19 return New(Config{
21 Root: root,20 Root: root,
22 RepoRoot: root,21 RepoRoot: root,
23- Worktree: MemWorktree(filepath.Join(root, "worktrees")),
24 NewRunner: mk,22 NewRunner: mk,
25 })23 })
26 }24 }
@@ -58,8 +56,25 @@ func TestSpawnRunsAndCompletes(t *testing.T) {
58 if !strings.Contains(a.ID, "do-a-thing") {56 if !strings.Contains(a.ID, "do-a-thing") {
59 t.Fatalf("id %q missing slug", a.ID)57 t.Fatalf("id %q missing slug", a.ID)
60 }58 }
61- if a.Branch != "swarm/"+a.ID {59+ // Every agent shares the host's RepoRoot.
62- t.Fatalf("branch = %q", a.Branch)60+ if a.Dir != f.cfg.RepoRoot {
61+ t.Fatalf("dir = %q; want repo root %q", a.Dir, f.cfg.RepoRoot)
62+ }
63+}
64+
65+// TestSpawnAgentSharesRepoRoot verifies the only-mode-we-support:
66+// every spawned agent points its cwd at the parent zot's RepoRoot.
67+func TestSpawnAgentSharesRepoRoot(t *testing.T) {
68+ f := newTestSwarm(t, func(a *Agent) Runner {
69+ return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
70+ })
71+ a, err := f.Spawn(context.Background(), "share me")
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ a.Wait()
76+ if a.Dir != f.cfg.RepoRoot {
77+ t.Fatalf("Dir = %q; want RepoRoot %q", a.Dir, f.cfg.RepoRoot)
63 }78 }
64 }79 }
65 80
D internal/swarm/worktree.go +0 -110
@@ -1,110 +0,0 @@
1-package swarm
2-
3-import (
4- "errors"
5- "fmt"
6- "os"
7- "os/exec"
8- "path/filepath"
9- "strings"
10-)
11-
12-// gitWorktree creates per-agent working directories via `git worktree
13-// add`. If the repo path isn't a git repository it falls back to a
14-// plain mkdir under root/<id>, which is still useful for tests and
15-// for running agents in non-git directories.
16-type gitWorktree struct {
17- root string // <swarm-root>/worktrees
18- repo string // user CWD; git operations resolve from here
19-}
20-
21-// Create makes a new worktree on branch off of HEAD. The branch is
22-// created if it doesn't already exist. Returns the absolute path.
23-func (g *gitWorktree) Create(id, branch, base string) (string, error) {
24- dir := filepath.Join(g.root, id)
25- if err := os.MkdirAll(g.root, 0o755); err != nil {
26- return "", err
27- }
28- if !isGitRepo(g.repo) {
29- // Non-git fallback: just a fresh directory. The agent works
30- // here; nothing is staged for merge.
31- if err := os.MkdirAll(dir, 0o755); err != nil {
32- return "", err
33- }
34- return dir, nil
35- }
36- cmd := exec.Command("git", "worktree", "add", "-b", branch, dir)
37- cmd.Dir = g.repo
38- out, err := cmd.CombinedOutput()
39- if err != nil {
40- // If the branch already exists (e.g. a leftover from a
41- // previous run), retry without -b so the user doesn't need
42- // to clean up by hand.
43- if strings.Contains(string(out), "already exists") {
44- cmd = exec.Command("git", "worktree", "add", dir, branch)
45- cmd.Dir = g.repo
46- out, err = cmd.CombinedOutput()
47- }
48- }
49- if err != nil {
50- return "", fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err)
51- }
52- return dir, nil
53-}
54-
55-// Remove deletes the worktree. Uses `git worktree remove --force` so
56-// dirty trees don't block cleanup; the user is the one running this
57-// command explicitly.
58-func (g *gitWorktree) Remove(id, dir string) error {
59- if dir == "" {
60- return errors.New("empty worktree dir")
61- }
62- if !isGitRepo(g.repo) {
63- return os.RemoveAll(dir)
64- }
65- cmd := exec.Command("git", "worktree", "remove", "--force", dir)
66- cmd.Dir = g.repo
67- out, err := cmd.CombinedOutput()
68- if err != nil {
69- // `git worktree remove` refuses to operate on an unknown path;
70- // in that case fall back to a plain rmdir so the user can
71- // always clean up.
72- if strings.Contains(string(out), "not a working tree") {
73- return os.RemoveAll(dir)
74- }
75- return fmt.Errorf("git worktree remove: %s: %w", strings.TrimSpace(string(out)), err)
76- }
77- return nil
78-}
79-
80-func isGitRepo(dir string) bool {
81- if dir == "" {
82- return false
83- }
84- cmd := exec.Command("git", "-C", dir, "rev-parse", "--git-dir")
85- return cmd.Run() == nil
86-}
87-
88-// memWorktree is a WorktreeManager used by tests. It does no git
89-// operations; it just makes a fresh subdirectory under root.
90-type memWorktree struct{ root string }
91-
92-// MemWorktree returns a WorktreeManager that creates plain
93-// subdirectories. Exposed for tests and for callers running zot
94-// outside a git repository.
95-func MemWorktree(root string) WorktreeManager { return &memWorktree{root: root} }
96-
97-func (m *memWorktree) Create(id, branch, base string) (string, error) {
98- dir := filepath.Join(m.root, id)
99- if err := os.MkdirAll(dir, 0o755); err != nil {
100- return "", err
101- }
102- return dir, nil
103-}
104-
105-func (m *memWorktree) Remove(id, dir string) error {
106- if dir == "" {
107- return errors.New("empty worktree dir")
108- }
109- return os.RemoveAll(dir)
110-}
@@ -1,110 +0,0 @@
1-package swarm
2-
3-import (
4- "errors"
5- "fmt"
6- "os"
7- "os/exec"
8- "path/filepath"
9- "strings"
10-)
11-
12-// gitWorktree creates per-agent working directories via `git worktree
13-// add`. If the repo path isn't a git repository it falls back to a
14-// plain mkdir under root/<id>, which is still useful for tests and
15-// for running agents in non-git directories.
16-type gitWorktree struct {
17- root string // <swarm-root>/worktrees
18- repo string // user CWD; git operations resolve from here
19-}
20-
21-// Create makes a new worktree on branch off of HEAD. The branch is
22-// created if it doesn't already exist. Returns the absolute path.
23-func (g *gitWorktree) Create(id, branch, base string) (string, error) {
24- dir := filepath.Join(g.root, id)
25- if err := os.MkdirAll(g.root, 0o755); err != nil {
26- return "", err
27- }
28- if !isGitRepo(g.repo) {
29- // Non-git fallback: just a fresh directory. The agent works
30- // here; nothing is staged for merge.
31- if err := os.MkdirAll(dir, 0o755); err != nil {
32- return "", err
33- }
34- return dir, nil
35- }
36- cmd := exec.Command("git", "worktree", "add", "-b", branch, dir)
37- cmd.Dir = g.repo
38- out, err := cmd.CombinedOutput()
39- if err != nil {
40- // If the branch already exists (e.g. a leftover from a
41- // previous run), retry without -b so the user doesn't need
42- // to clean up by hand.
43- if strings.Contains(string(out), "already exists") {
44- cmd = exec.Command("git", "worktree", "add", dir, branch)
45- cmd.Dir = g.repo
46- out, err = cmd.CombinedOutput()
47- }
48- }
49- if err != nil {
50- return "", fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err)
51- }
52- return dir, nil
53-}
54-
55-// Remove deletes the worktree. Uses `git worktree remove --force` so
56-// dirty trees don't block cleanup; the user is the one running this
57-// command explicitly.
58-func (g *gitWorktree) Remove(id, dir string) error {
59- if dir == "" {
60- return errors.New("empty worktree dir")
61- }
62- if !isGitRepo(g.repo) {
63- return os.RemoveAll(dir)
64- }
65- cmd := exec.Command("git", "worktree", "remove", "--force", dir)
66- cmd.Dir = g.repo
67- out, err := cmd.CombinedOutput()
68- if err != nil {
69- // `git worktree remove` refuses to operate on an unknown path;
70- // in that case fall back to a plain rmdir so the user can
71- // always clean up.
72- if strings.Contains(string(out), "not a working tree") {
73- return os.RemoveAll(dir)
74- }
75- return fmt.Errorf("git worktree remove: %s: %w", strings.TrimSpace(string(out)), err)
76- }
77- return nil
78-}
79-
80-func isGitRepo(dir string) bool {
81- if dir == "" {
82- return false
83- }
84- cmd := exec.Command("git", "-C", dir, "rev-parse", "--git-dir")
85- return cmd.Run() == nil
86-}
87-
88-// memWorktree is a WorktreeManager used by tests. It does no git
89-// operations; it just makes a fresh subdirectory under root.
90-type memWorktree struct{ root string }
91-
92-// MemWorktree returns a WorktreeManager that creates plain
93-// subdirectories. Exposed for tests and for callers running zot
94-// outside a git repository.
95-func MemWorktree(root string) WorktreeManager { return &memWorktree{root: root} }
96-
97-func (m *memWorktree) Create(id, branch, base string) (string, error) {
98- dir := filepath.Join(m.root, id)
99- if err := os.MkdirAll(dir, 0o755); err != nil {
100- return "", err
101- }
102- return dir, nil
103-}
104-
105-func (m *memWorktree) Remove(id, dir string) error {
106- if dir == "" {
107- return errors.New("empty worktree dir")
108- }
109- return os.RemoveAll(dir)
110-}

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