From 1aea23e419f122901935fc81fa45a4697cb67953 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Sun, 17 May 2026 00:01:29 +0200 Subject: [PATCH] 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/ 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 /agents//. - runner.go drops the /.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 /agents//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. --- README.md | 48 +++++----- internal/agent/modes/slash_suggest.go | 2 +- internal/agent/modes/swarm_dialog.go | 17 ++-- internal/agent/modes/swarm_dialog_test.go | 2 +- internal/agent/modes/swarm_slash.go | 4 +- internal/agent/modes/swarm_slash_test.go | 3 - internal/swarm/agent.go | 3 +- internal/swarm/persist.go | 25 +++-- internal/swarm/persist_test.go | 38 +++----- internal/swarm/runner.go | 34 ++++++- internal/swarm/runner_e2e_test.go | 1 - internal/swarm/swarm.go | 97 +++++++++---------- internal/swarm/swarm_test.go | 29 ++++-- internal/swarm/worktree.go | 110 ---------------------- 14 files changed, 166 insertions(+), 247 deletions(-) delete mode 100644 internal/swarm/worktree.go diff --git a/README.md b/README.md index 3e73d9f..e9aeb70 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | `/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 `, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. | | `/jump` | Scroll the chat to a previous turn (or `/jump ` to filter). | | `/btw` | Side chat with full context that doesn't add to the main thread. | -| `/swarm` | Spawn, monitor, and chat with background subagents. Each gets its own git worktree and runs in parallel with your main session. | +| `/swarm` | Spawn, monitor, and chat with background subagents. Each runs in parallel with your main session and shares its working directory. | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | | `/compact` | Summarize the transcript into one message to free up context. | | `/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 - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/--.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. - **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/` on branch `swarm/`), 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. + **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. - **`/session import `**. Copies a `.zotsession` file into `$ZOT_HOME/sessions//` 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). - **`/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. - **`/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 ### `/swarm` -Long-running parallel subagents. Each one gets its own git worktree off your current repo (branch `swarm/`), 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. +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. -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. +> **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. ``` /swarm # open the dashboard -/swarm new # spawn an agent on a fresh worktree -/swarm new --model gpt-5 # pin the new agent to a specific model +/swarm new # spawn an agent +/swarm new --model gpt-5 # pin the new agent to a specific model /swarm logs # jump straight into one agent's transcript /swarm send # send a follow-up without opening the dashboard /swarm resume # pick a stopped agent to bring back /swarm resume # bring a specific agent back -/swarm kill # stop a running agent (worktree stays) -/swarm remove # delete the worktree + session for an agent +/swarm kill # stop a running agent (its state stays) +/swarm remove # delete the agent's session and state /swarm list # alias for opening the dashboard ``` -**Dashboard (`/swarm` with no arg)** — a list of every agent for the current session with status, age, and current activity. Keys: +**Dashboard (`/swarm` with no arg)** — a list of every agent for the current session, with status, age, and current activity. Keys: -- `↑` / `↓` move the cursor between rows. -- `enter` opens the highlighted agent's transcript view. -- `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). -- `p` opens a one-off prompt editor for the selected row (alternative to entering the transcript). -- `R` resumes a stopped agent in place. -- `k` kills the selected running agent (its worktree and session stay so you can resume later). -- `r` removes the selected agent entirely (worktree + session + meta gone). -- `esc` closes the dashboard. +| Key | Action | +|---|---| +| `↑` / `↓` | Move cursor between rows. | +| `enter` | Open the highlighted agent's transcript view. | +| `n` | Spawn a new agent (opens an inline task editor; inherits the host's current model). | +| `p` | One-off prompt editor for the selected row (without entering the transcript). | +| `R` | Resume a stopped agent in place. | +| `k` | Kill the selected running agent. Its session and state stay so you can resume it later. | +| `r` | Remove the selected agent entirely (session + meta gone). | +| `esc` | Close the dashboard. | -**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. +**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. -**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. +**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. -**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. +**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. -**Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents//`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume `) 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. +**Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents//`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume `) to bring one back. Resumed agents reattach to the same session and inbox socket, so the conversation continues from where it left off. -**Where their work lives** — worktrees go under `$ZOT_HOME/swarm/worktrees/` on the branch `swarm/`. Use the normal git tooling to inspect, merge, or rebase (`git worktree list`, `git log swarm/`, etc.). `/swarm remove` deletes both the worktree and the swarm bookkeeping. +**Where state lives** — everything per-agent (session file, events log, inbox socket, meta) lives under `$ZOT_HOME/swarm/agents//`. The agent's actual code edits land directly in your repo; track them with normal `git status` / `git diff`. -**`/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/` branch with the normal git tooling. To share what it said, copy it out of the transcript view manually. +**`/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. ### `/skills` diff --git a/internal/agent/modes/slash_suggest.go b/internal/agent/modes/slash_suggest.go index d35e575..c09b2a5 100644 --- a/internal/agent/modes/slash_suggest.go +++ b/internal/agent/modes/slash_suggest.go @@ -45,7 +45,7 @@ var slashCatalog = []slashCommand{ {Name: "/jail", Desc: "confine tools to the current directory"}, {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"}, {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"}, - {Name: "/swarm", Desc: "supervise background agents working in their own worktrees"}, + {Name: "/swarm", Desc: "supervise background agents that share this working directory"}, {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, {Name: "/clear", Desc: "clear the chat transcript"}, diff --git a/internal/agent/modes/swarm_dialog.go b/internal/agent/modes/swarm_dialog.go index 6bb77f2..ba6cb24 100644 --- a/internal/agent/modes/swarm_dialog.go +++ b/internal/agent/modes/swarm_dialog.go @@ -23,7 +23,7 @@ import ( // ↑/↓ move cursor // enter show transcript tail for the selected agent // k kill (Stop) the selected running agent -// r remove a terminated agent (deletes worktree) +// r remove a terminated agent (clears its state) // esc / q close // // Keys (transcript view): @@ -44,8 +44,8 @@ type swarmDialog struct { // Wired by Open(); when nil the inline 'p' shortcut is disabled. send func(id, text string) error // resume restarts a detached or terminated agent on its existing - // worktree/session. Wired by Open(); when nil the inline 'R' - // shortcut is disabled. + // session. Wired by Open(); when nil the inline 'R' shortcut is + // disabled. resume func(id string) error rows []swarm.AgentSnapshot @@ -240,7 +240,7 @@ func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffse return -1 } row := 1 // frame header - row += 4 // task / branch / dir / status + row += 3 // task / dir / status (mirrors renderTranscript's fixed header rows) if a.Model != "" { row++ } @@ -400,7 +400,7 @@ func promptDisabledHint(s swarm.Status) string { // killDisabledHint mirrors promptDisabledHint for the 'k' shortcut. // Kill only makes sense on running / pending agents; on detached and // terminal ones it's a no-op and the user usually wants 'r' (remove) -// to clean up the worktree instead. +// to clear out the agent's state instead. func killDisabledHint(s swarm.Status) string { return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)" } @@ -1033,7 +1033,6 @@ func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string { header := []string{ frameHeader(th, "swarm: "+a.ID+" (type to send, esc back)", width), " " + th.FG256(th.Muted, "task: "+a.Task), - " " + th.FG256(th.Muted, "branch: "+a.Branch), " " + th.FG256(th.Muted, "dir: "+a.Dir), " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)), } @@ -1321,6 +1320,12 @@ func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) } // formatSwarmRow is the one-line summary shown per agent. +// +// Layout (fixed-width columns, then free-form activity): +// +// STATUS ID AGE ACTIVITY +// ● run fix-login-12345 3m editing main.go +// ✓ done write-tests-67890 1h done func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string { status := statusLabel(r.Status) age := formatAge(r.Started) diff --git a/internal/agent/modes/swarm_dialog_test.go b/internal/agent/modes/swarm_dialog_test.go index e8a5201..e954413 100644 --- a/internal/agent/modes/swarm_dialog_test.go +++ b/internal/agent/modes/swarm_dialog_test.go @@ -100,7 +100,7 @@ func TestSwarmDialogEnterShowsTranscript(t *testing.T) { _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyEnter}) out := strings.Join(d.Render(tui.Theme{}, 80), "\n") - for _, want := range []string{"task:", "branch:", "line a", "line b"} { + for _, want := range []string{"task:", "dir:", "line a", "line b"} { if !strings.Contains(out, want) { t.Fatalf("transcript view missing %q:\n%s", want, out) } diff --git a/internal/agent/modes/swarm_slash.go b/internal/agent/modes/swarm_slash.go index 531e4ae..0d49d9a 100644 --- a/internal/agent/modes/swarm_slash.go +++ b/internal/agent/modes/swarm_slash.go @@ -110,9 +110,9 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { return } if model != "" { - i.swarmStatus("spawned "+a.ID+" on "+a.Branch+" (model "+model+")", "") + i.swarmStatus("spawned "+a.ID+" (model "+model+")", "") } else { - i.swarmStatus("spawned "+a.ID+" on "+a.Branch, "") + i.swarmStatus("spawned "+a.ID, "") } case "kill", "stop": if rest == "" { diff --git a/internal/agent/modes/swarm_slash_test.go b/internal/agent/modes/swarm_slash_test.go index b101237..6a1ee3c 100644 --- a/internal/agent/modes/swarm_slash_test.go +++ b/internal/agent/modes/swarm_slash_test.go @@ -2,7 +2,6 @@ package modes import ( "context" - "path/filepath" "testing" "time" @@ -19,7 +18,6 @@ func newInteractiveForSwarmTest(t *testing.T) (*Interactive, *swarm.Swarm) { f := swarm.New(swarm.Config{ Root: root, RepoRoot: root, - Worktree: swarm.MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *swarm.Agent) swarm.Runner { return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { <-ctx.Done() @@ -107,7 +105,6 @@ func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) { f := swarm.New(swarm.Config{ Root: root, RepoRoot: root, - Worktree: swarm.MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *swarm.Agent) swarm.Runner { return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { // Stand up a real Listener on the agent's inbox path so diff --git a/internal/swarm/agent.go b/internal/swarm/agent.go index af7b1a6..c7d10bc 100644 --- a/internal/swarm/agent.go +++ b/internal/swarm/agent.go @@ -14,8 +14,7 @@ import ( type Agent struct { ID string Task string - Branch string - Dir string + Dir string // always the host's RepoRoot; agents share its cwd. Started time.Time // Model and Provider, when non-empty, override the child diff --git a/internal/swarm/persist.go b/internal/swarm/persist.go index db4c50c..0072871 100644 --- a/internal/swarm/persist.go +++ b/internal/swarm/persist.go @@ -33,10 +33,13 @@ import ( // the supervisor needs to rebuild an Agent after a restart live here. // Adding a field is backwards-compatible (older meta.json files just // leave it zero); removing or renaming one is not. +// +// Historical fields like `branch` and `isolated` are silently dropped +// by encoding/json's permissive decoder when an older meta.json is +// loaded; we don't need to keep them in the struct. type agentMeta struct { ID string `json:"id"` Task string `json:"task"` - Branch string `json:"branch"` Dir string `json:"dir"` Started time.Time `json:"started"` Model string `json:"model,omitempty"` @@ -51,8 +54,7 @@ type agentMeta struct { // (and agents spawned outside of any session, e.g. by tests or // scripted callers that didn't call SetActiveSession) have an // empty SessionID and are visible from every session as a - // backward-compat fallback. Added in 2026 — a fresh field on a - // json struct is backwards-compatible by design. + // backward-compat fallback. SessionID string `json:"session_id,omitempty"` } @@ -65,7 +67,6 @@ func writeAgentMeta(stateDir string, a *Agent) error { m := agentMeta{ ID: a.ID, Task: a.Task, - Branch: a.Branch, Dir: a.Dir, Started: a.Started, Model: a.Model, @@ -187,11 +188,20 @@ func (f *Swarm) Reload() (loaded int, errs []error) { // The returned Agent has a closed `done` channel because Wait should // return instantly: there is nothing to wait for. func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { + // Older meta.json files may still record a per-agent worktree + // path under Dir. They predate the decision to run every agent + // in the host's repo and shouldn't continue editing that stale + // checkout, which most likely no longer matches HEAD. Coerce + // the dir back to the live RepoRoot so resume picks up where + // the host is now. + dir := m.Dir + if f.cfg.RepoRoot != "" { + dir = f.cfg.RepoRoot + } a := &Agent{ ID: m.ID, Task: m.Task, - Branch: m.Branch, - Dir: m.Dir, + Dir: dir, Started: m.Started, Model: m.Model, Provider: m.Provider, @@ -334,7 +344,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { // (e.g. tests that hand-built an Agent) don't accidentally route // the new runner at the wrong paths. m := agentMeta{ - ID: existing.ID, Task: existing.Task, Branch: existing.Branch, + ID: existing.ID, Task: existing.Task, Dir: existing.Dir, Started: existing.Started, Model: existing.Model, Provider: existing.Provider, InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath, @@ -344,7 +354,6 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { a := &Agent{ ID: m.ID, Task: m.Task, - Branch: m.Branch, Dir: m.Dir, Started: m.Started, Model: m.Model, diff --git a/internal/swarm/persist_test.go b/internal/swarm/persist_test.go index e552812..6559419 100644 --- a/internal/swarm/persist_test.go +++ b/internal/swarm/persist_test.go @@ -21,7 +21,6 @@ func TestSpawnWritesMetaJSON(t *testing.T) { f := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *Agent) Runner { return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done() @@ -51,7 +50,7 @@ func TestSpawnWritesMetaJSON(t *testing.T) { if got.Task != "investigate widget" { t.Errorf("meta.Task = %q", got.Task) } - if got.Branch != a.Branch || got.Dir != a.Dir { + if got.Dir != a.Dir { t.Errorf("meta paths drifted: %+v vs agent %+v", got, a) } if got.InboxPath == "" || got.EventLogPath == "" || got.SessionPath == "" { @@ -74,7 +73,6 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) { first := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *Agent) Runner { return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done() @@ -101,7 +99,6 @@ func TestReloadRebuildsDetachedAgents(t *testing.T) { second := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), }) loaded, errs := second.Reload() if len(errs) > 0 { @@ -140,7 +137,6 @@ func TestReloadIsIdempotent(t *testing.T) { root := t.TempDir() first := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *Agent) Runner { return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) }, @@ -150,7 +146,7 @@ func TestReloadIsIdempotent(t *testing.T) { } first.StopAll() - second := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) + second := New(Config{Root: root, RepoRoot: root}) loaded1, _ := second.Reload() loaded2, errs := second.Reload() if len(errs) > 0 { @@ -177,8 +173,8 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { } // meta.json m := agentMeta{ - ID: id, Task: "do thing", Branch: "swarm/alpha-9", - Dir: filepath.Join(root, "wt", id), Started: time.Now().Add(-time.Hour), + ID: id, Task: "do thing", + Dir: root, Started: time.Now().Add(-time.Hour), InboxPath: filepath.Join(stateDir, "in.sock"), EventLogPath: filepath.Join(stateDir, "events.jsonl"), SessionPath: filepath.Join(stateDir, "session.json"), @@ -198,7 +194,7 @@ func TestReloadReplaysTranscriptFromEventLog(t *testing.T) { _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "shutdown"})) _ = log.Close() - f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) + f := New(Config{Root: root, RepoRoot: root}) loaded, errs := f.Reload() if len(errs) > 0 || loaded != 1 { t.Fatalf("reload loaded=%d errs=%v", loaded, errs) @@ -246,11 +242,11 @@ func TestReloadSkipsBareDirsAndCorruptMeta(t *testing.T) { good := "good-1" stateDir := filepath.Join(agentsDir, good) _ = os.MkdirAll(stateDir, 0o755) - m := agentMeta{ID: good, Task: "x", Branch: "swarm/" + good, Dir: "/tmp/x", Started: time.Now()} + m := agentMeta{ID: good, Task: "x", Dir: "/tmp/x", Started: time.Now()} mb, _ := json.MarshalIndent(m, "", " ") _ = os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644) - f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) + f := New(Config{Root: root, RepoRoot: root}) loaded, errs := f.Reload() if loaded != 1 { t.Errorf("loaded = %d; want 1", loaded) @@ -276,7 +272,6 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) { ) f := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *Agent) Runner { return RunnerFunc(func(ctx context.Context, sink Sink) error { mu.Lock() @@ -358,7 +353,6 @@ func TestResumeSetsResumingFlag(t *testing.T) { root := t.TempDir() f := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *Agent) Runner { return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) }, @@ -392,7 +386,6 @@ func TestResumeRejectsRunningAgent(t *testing.T) { root := t.TempDir() f := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *Agent) Runner { return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) }, @@ -430,7 +423,6 @@ func TestResumeAfterReload(t *testing.T) { // Process A a := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(ag *Agent) Runner { return RunnerFunc(func(ctx context.Context, sink Sink) error { sink.Transcript("first run for " + ag.ID) @@ -456,7 +448,6 @@ func TestResumeAfterReload(t *testing.T) { resumed := make(chan struct{}, 1) b := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(ag *Agent) Runner { return RunnerFunc(func(ctx context.Context, sink Sink) error { sink.Transcript("second run for " + ag.ID) @@ -507,7 +498,6 @@ func TestSpawnReqPersistsModel(t *testing.T) { root := t.TempDir() f := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *Agent) Runner { return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) }, @@ -547,7 +537,7 @@ func TestSpawnReqPersistsModel(t *testing.T) { // Reload in a fresh Swarm and confirm the detached agent still // carries the model/provider so Resume can route the child // subprocess back to the same model. - g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) + g := New(Config{Root: root, RepoRoot: root}) if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 { t.Fatalf("reload loaded=%d errs=%v", loaded, errs) } @@ -605,8 +595,8 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) { t.Fatal(err) } m := agentMeta{ - ID: id, Task: "t", Branch: "swarm/" + id, - Dir: filepath.Join(root, "wt", id), + ID: id, Task: "t", + Dir: root, Started: time.Now().Add(-time.Hour), InboxPath: filepath.Join(stateDir, "in.sock"), EventLogPath: filepath.Join(stateDir, "events.jsonl"), @@ -617,7 +607,7 @@ func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) { t.Fatal(err) } - f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) + f := New(Config{Root: root, RepoRoot: root}) if loaded, errs := f.Reload(); loaded != 1 || len(errs) > 0 { t.Fatalf("reload loaded=%d errs=%v", loaded, errs) } @@ -654,7 +644,6 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) { root := t.TempDir() f := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *Agent) Runner { return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) }, @@ -679,7 +668,7 @@ func TestRemoveAlsoCleansStateDir(t *testing.T) { } // A fresh Swarm + Reload should find nothing. - g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))}) + g := New(Config{Root: root, RepoRoot: root}) if loaded, _ := g.Reload(); loaded != 0 { t.Fatalf("reload after remove loaded=%d; want 0", loaded) } @@ -694,7 +683,6 @@ func TestActiveSessionScopesSnapshotAll(t *testing.T) { root := t.TempDir() f := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *Agent) Runner { return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) }, @@ -759,7 +747,6 @@ func TestSessionIDPersistsAcrossReload(t *testing.T) { mkSwarm := func() *Swarm { return New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *Agent) Runner { return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) }, @@ -803,7 +790,6 @@ func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) { root := t.TempDir() f := New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *Agent) Runner { return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() }) }, diff --git a/internal/swarm/runner.go b/internal/swarm/runner.go index 447a596..8ea638c 100644 --- a/internal/swarm/runner.go +++ b/internal/swarm/runner.go @@ -14,7 +14,8 @@ import ( ) // execRunner spawns `zot --swarm-agent --session ` in -// the agent's worktree and consumes its JSONL event stream on stdout. +// the host's working directory (Agent.Dir, which is always the parent +// zot's RepoRoot) and consumes its JSONL event stream on stdout. // // Why a long-lived daemon and not `zot --print`: the supervisor and // the user expect agents to keep accepting follow-up prompts. A @@ -40,9 +41,14 @@ type execRunner struct { // tested without a real child. Production code leaves it nil. Command []string - // SessionPath is the agent's session file. When empty the - // runner derives it as /.zot/session.json so each agent - // owns its own session inside its worktree. + // SessionPath is the agent's session file. Empty means "defer + // to r.agent.SessionPath", which Swarm.Spawn always populates + // with /agents//session.json. Tests that + // hand-build an Agent without going through Spawn must set + // one of the two; the runner refuses to invent a fallback + // because the only plausible one (/.zot/session.json) + // would litter the user's repo — every agent's Dir points + // at it directly. SessionPath string } @@ -116,9 +122,27 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string { } func (r *execRunner) Run(ctx context.Context, sink Sink) error { + // SessionPath resolution order: + // 1. explicit r.SessionPath set by the test / caller + // 2. r.agent.SessionPath baked in by Swarm.Spawn — the + // production path. Always lives under + // /agents//session.json so the per- + // agent state is entirely outside the working tree. + // Crucial because Agent.Dir points at the user's repo; + // any .zot/ scratch directory under Dir would litter + // their source tree. + // + // There is no third fallback. If neither path is set we + // refuse to start instead of inventing a directory; that + // way a misconfigured caller fails loudly the first time + // instead of silently dumping session data into someone's + // repo. sessionPath := r.SessionPath if sessionPath == "" { - sessionPath = filepath.Join(r.agent.Dir, ".zot", "session.json") + sessionPath = r.agent.SessionPath + } + if sessionPath == "" { + return fmt.Errorf("swarm: agent missing session path (set SpawnRequest via Swarm.SpawnReq, or hand-build Agent with SessionPath populated)") } if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { return fmt.Errorf("session dir: %w", err) diff --git a/internal/swarm/runner_e2e_test.go b/internal/swarm/runner_e2e_test.go index 78f3dbd..c9f8a3a 100644 --- a/internal/swarm/runner_e2e_test.go +++ b/internal/swarm/runner_e2e_test.go @@ -46,7 +46,6 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) { f := New(Config{ Root: root, RepoRoot: repo, - Worktree: MemWorktree(filepath.Join(root, "wt")), NewRunner: func(a *Agent) Runner { return &execRunner{ agent: a, diff --git a/internal/swarm/swarm.go b/internal/swarm/swarm.go index f92f203..42ccb0e 100644 --- a/internal/swarm/swarm.go +++ b/internal/swarm/swarm.go @@ -1,20 +1,26 @@ // Package swarm implements zot's multi-agent supervisor. // // A Swarm manages a set of headless zot subprocesses ("agents") -// working in their own git worktrees. The interactive TUI exposes -// the supervisor through the /swarm slash command and a dashboard -// dialog; non-TUI code can drive it directly through this package. +// that share the host's working directory. The interactive TUI +// exposes the supervisor through the /swarm slash command and a +// dashboard dialog; non-TUI code can drive it directly through +// this package. +// +// Every agent runs with cwd == the parent zot's RepoRoot — the +// same files the user sees, the same files the main agent edits. +// There is no git worktree, no per-agent branch, no isolation. If +// you want parallel edits on a separate branch, use normal git +// tooling (a real worktree, a different terminal) yourself. // // Each Agent has: // - a unique id (short slug + nanoseconds) -// - a branch name on a fresh git worktree under /swarm/ // - a Runner (the thing that actually executes the task) // - a Status string + Activity string that the dashboard reads // // The Runner abstraction means tests can swap a fake in instead of // really spawning a subprocess; the production Runner shells out to -// `zot --print` so we reuse zot's own model resolution and tooling -// without re-implementing the agent loop. +// `zot --swarm-agent ...` so we reuse zot's own model resolution +// and tooling without re-implementing the agent loop. package swarm import ( @@ -43,32 +49,24 @@ const ( // Config configures a Swarm. type Config struct { - // Root is the directory under which worktrees + state files live. + // Root is the directory under which per-agent state files live. // Typically /swarm, but tests pass a tempdir. Root string - // RepoRoot is the path to the user's git repo (CWD of the parent - // zot). New worktrees branch off this repo. + // RepoRoot is the working directory every spawned agent runs + // in — the same cwd the parent zot is using. There is no + // per-agent isolation: agents edit the host's files directly. RepoRoot string - // Worktree creates the per-agent working directory. If nil, the - // default git-worktree implementation is used. - Worktree WorktreeManager - // NewRunner produces the Runner for an Agent. If nil, the default - // `zot --print` exec runner is used. Tests inject a fake here. + // `zot --swarm-agent ...` exec runner is used. Tests inject a fake + // here. NewRunner func(a *Agent) Runner // Now is a clock seam for tests; defaults to time.Now. Now func() time.Time } -// WorktreeManager creates and removes per-agent working directories. -type WorktreeManager interface { - Create(id, branch, base string) (dir string, err error) - Remove(id, dir string) error -} - // Runner executes one agent task. Run blocks until the task finishes, // is cancelled via ctx, or hits an unrecoverable error. // @@ -118,9 +116,6 @@ func New(cfg Config) *Swarm { if cfg.Now == nil { cfg.Now = time.Now } - if cfg.Worktree == nil { - cfg.Worktree = &gitWorktree{root: filepath.Join(cfg.Root, "worktrees"), repo: cfg.RepoRoot} - } if cfg.NewRunner == nil { cfg.NewRunner = func(a *Agent) Runner { return &execRunner{agent: a} } } @@ -158,7 +153,7 @@ func (f *Swarm) ActiveSession() string { // events.jsonl durable event log (runner-owned) // in.sock unix socket inbox (child-owned) // session.json persistent agent session (child-owned) -// meta.json static metadata (id, task, branch, dir) +// meta.json static metadata (id, task) func (f *Swarm) agentStateDir(id string) string { return filepath.Join(f.cfg.Root, "agents", id) } @@ -173,13 +168,13 @@ type SpawnRequest struct { Provider string // optional override; usually paired with Model } -// Spawn creates a new Agent for the given task, sets up its worktree, -// allocates the on-disk state directory (events log, inbox socket -// path, session file path), and starts the Runner on a background -// goroutine. The returned Agent is already in StatusRunning (or -// StatusFailed if worktree setup failed before the goroutine -// started). This is the historical signature; callers that want to -// override the child's model use SpawnReq instead. +// Spawn creates a new Agent for the given task, allocates its +// on-disk state directory (events log, inbox socket path, session +// file path), and starts the Runner on a background goroutine. The +// returned Agent is already in StatusRunning (or StatusFailed if +// state setup failed before the goroutine started). This is the +// historical signature; callers that want to override the child's +// model use SpawnReq instead. func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) { return f.SpawnReq(ctx, SpawnRequest{Task: task}) } @@ -187,17 +182,17 @@ func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) { // SpawnReq is the full-fat variant of Spawn that accepts a // SpawnRequest. Existing callers can keep using Spawn; new code that // wants to pin the child's model uses this. +// +// Every spawned agent runs with cwd == cfg.RepoRoot — the same +// working directory as the host. No per-agent worktree, no branch, +// no isolation. The user explicitly opted out of the worktree flow. func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) { task := strings.TrimSpace(req.Task) if task == "" { return nil, errors.New("swarm: empty task") } id := newAgentID(task, f.cfg.Now()) - branch := "swarm/" + id - dir, err := f.cfg.Worktree.Create(id, branch, f.cfg.RepoRoot) - if err != nil { - return nil, fmt.Errorf("worktree create: %w", err) - } + dir := f.cfg.RepoRoot stateDir := f.agentStateDir(id) if err := os.MkdirAll(stateDir, 0o755); err != nil { @@ -226,7 +221,6 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) a := &Agent{ ID: id, Task: task, - Branch: branch, Dir: dir, Started: f.cfg.Now(), Model: strings.TrimSpace(req.Model), @@ -385,11 +379,15 @@ func (f *Swarm) StopAll() { } } -// Remove tears down the worktree for a terminated agent. It is an -// error to remove an agent that's still running; call Stop first and -// wait for the status to settle. Detached agents (reloaded from -// disk) remove cleanly because they have no live runner racing for -// the same files. +// Remove tears down the per-agent state for a terminated agent. It +// is an error to remove an agent that's still running; call Stop +// first and wait for the status to settle. Detached agents +// (reloaded from disk) remove cleanly because they have no live +// runner racing for the same files. +// +// Agents share the host's working tree, so Remove never touches +// any source file — it only deletes the agent's state directory +// under /agents//. func (f *Swarm) Remove(id string) error { a := f.Get(id) if a == nil { @@ -397,18 +395,14 @@ func (f *Swarm) Remove(id string) error { } a.mu.Lock() st := a.status - dir := a.Dir a.mu.Unlock() if st == StatusRunning || st == StatusPending { return fmt.Errorf("agent %s still %s", a.ID, st) } - if err := f.cfg.Worktree.Remove(a.ID, dir); err != nil { - return err - } - // Best-effort cleanup of the per-agent state directory (meta.json, - // events.jsonl, session.json, in.sock if it's local). Worktree - // removal already succeeded; failing here would leave the user - // with no recourse, so swallow the error. + // Best-effort cleanup of the per-agent state directory + // (meta.json, events.jsonl, session.json, in.sock if it's + // local). Failing here would leave the user with no recourse, + // so swallow the error. _ = os.RemoveAll(f.agentStateDir(a.ID)) f.mu.Lock() delete(f.agents, a.ID) @@ -427,7 +421,6 @@ func (f *Swarm) Remove(id string) error { type AgentSnapshot struct { ID string Task string - Branch string Dir string Status Status Activity string @@ -465,7 +458,7 @@ func (a *Agent) Snapshot() AgentSnapshot { errStr = a.lastErr.Error() } return AgentSnapshot{ - ID: a.ID, Task: a.Task, Branch: a.Branch, Dir: a.Dir, + ID: a.ID, Task: a.Task, Dir: a.Dir, Status: a.status, Activity: a.activity, Started: a.Started, Finished: a.finished, Err: errStr, Tail: tail, Lines: lines, diff --git a/internal/swarm/swarm_test.go b/internal/swarm/swarm_test.go index c654161..4d0e7ed 100644 --- a/internal/swarm/swarm_test.go +++ b/internal/swarm/swarm_test.go @@ -3,7 +3,6 @@ package swarm import ( "context" "errors" - "path/filepath" "strings" "sync" "testing" @@ -11,16 +10,15 @@ import ( ) // newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory -// worktree and a Runner factory the test controls. Returns the swarm -// plus a slice of runners keyed by spawn order so tests can assert -// they were actually invoked. +// and a Runner factory the test controls. Returns the swarm plus a +// slice of runners keyed by spawn order so tests can assert they +// were actually invoked. func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm { t.Helper() root := t.TempDir() return New(Config{ Root: root, RepoRoot: root, - Worktree: MemWorktree(filepath.Join(root, "worktrees")), NewRunner: mk, }) } @@ -58,8 +56,25 @@ func TestSpawnRunsAndCompletes(t *testing.T) { if !strings.Contains(a.ID, "do-a-thing") { t.Fatalf("id %q missing slug", a.ID) } - if a.Branch != "swarm/"+a.ID { - t.Fatalf("branch = %q", a.Branch) + // Every agent shares the host's RepoRoot. + if a.Dir != f.cfg.RepoRoot { + t.Fatalf("dir = %q; want repo root %q", a.Dir, f.cfg.RepoRoot) + } +} + +// TestSpawnAgentSharesRepoRoot verifies the only-mode-we-support: +// every spawned agent points its cwd at the parent zot's RepoRoot. +func TestSpawnAgentSharesRepoRoot(t *testing.T) { + f := newTestSwarm(t, func(a *Agent) Runner { + return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil }) + }) + a, err := f.Spawn(context.Background(), "share me") + if err != nil { + t.Fatal(err) + } + a.Wait() + if a.Dir != f.cfg.RepoRoot { + t.Fatalf("Dir = %q; want RepoRoot %q", a.Dir, f.cfg.RepoRoot) } } diff --git a/internal/swarm/worktree.go b/internal/swarm/worktree.go deleted file mode 100644 index c905881..0000000 --- a/internal/swarm/worktree.go +++ /dev/null @@ -1,110 +0,0 @@ -package swarm - -import ( - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" -) - -// gitWorktree creates per-agent working directories via `git worktree -// add`. If the repo path isn't a git repository it falls back to a -// plain mkdir under root/, which is still useful for tests and -// for running agents in non-git directories. -type gitWorktree struct { - root string // /worktrees - repo string // user CWD; git operations resolve from here -} - -// Create makes a new worktree on branch off of HEAD. The branch is -// created if it doesn't already exist. Returns the absolute path. -func (g *gitWorktree) Create(id, branch, base string) (string, error) { - dir := filepath.Join(g.root, id) - if err := os.MkdirAll(g.root, 0o755); err != nil { - return "", err - } - if !isGitRepo(g.repo) { - // Non-git fallback: just a fresh directory. The agent works - // here; nothing is staged for merge. - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - return dir, nil - } - cmd := exec.Command("git", "worktree", "add", "-b", branch, dir) - cmd.Dir = g.repo - out, err := cmd.CombinedOutput() - if err != nil { - // If the branch already exists (e.g. a leftover from a - // previous run), retry without -b so the user doesn't need - // to clean up by hand. - if strings.Contains(string(out), "already exists") { - cmd = exec.Command("git", "worktree", "add", dir, branch) - cmd.Dir = g.repo - out, err = cmd.CombinedOutput() - } - } - if err != nil { - return "", fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err) - } - return dir, nil -} - -// Remove deletes the worktree. Uses `git worktree remove --force` so -// dirty trees don't block cleanup; the user is the one running this -// command explicitly. -func (g *gitWorktree) Remove(id, dir string) error { - if dir == "" { - return errors.New("empty worktree dir") - } - if !isGitRepo(g.repo) { - return os.RemoveAll(dir) - } - cmd := exec.Command("git", "worktree", "remove", "--force", dir) - cmd.Dir = g.repo - out, err := cmd.CombinedOutput() - if err != nil { - // `git worktree remove` refuses to operate on an unknown path; - // in that case fall back to a plain rmdir so the user can - // always clean up. - if strings.Contains(string(out), "not a working tree") { - return os.RemoveAll(dir) - } - return fmt.Errorf("git worktree remove: %s: %w", strings.TrimSpace(string(out)), err) - } - return nil -} - -func isGitRepo(dir string) bool { - if dir == "" { - return false - } - cmd := exec.Command("git", "-C", dir, "rev-parse", "--git-dir") - return cmd.Run() == nil -} - -// memWorktree is a WorktreeManager used by tests. It does no git -// operations; it just makes a fresh subdirectory under root. -type memWorktree struct{ root string } - -// MemWorktree returns a WorktreeManager that creates plain -// subdirectories. Exposed for tests and for callers running zot -// outside a git repository. -func MemWorktree(root string) WorktreeManager { return &memWorktree{root: root} } - -func (m *memWorktree) Create(id, branch, base string) (string, error) { - dir := filepath.Join(m.root, id) - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - return dir, nil -} - -func (m *memWorktree) Remove(id, dir string) error { - if dir == "" { - return errors.New("empty worktree dir") - } - return os.RemoveAll(dir) -}