SLOPSHOPPER

worktrees

Gives every agent that can edit a git worktree of its own, runs a project's setup script with its own port range, kills what an agent left running, removes an…

newcommandtoastprocesstimer
★ 1v0.4.0MITupdated 2026-10-14AbhiramDwivedi/claude-ops/plugins/worktrees
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · worktrees
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tidy ⎿ worktrees: Not in a git repository with a main checkout. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

worktrees

Keeps agents from stepping on each other's files, and cleans up after them.

  • Every agent that can edit gets a git worktree. When an Agent call leaves isolation unset and the session is in a git repo, the mod sets isolation: "worktree". Read-only agent types run in the session's checkout: Explore, Plan, claude-code-guide, statusline-setup, scout, reviewer (also with a plugin prefix, such as kit:scout), plus any you list in readOnlyAgents. A call that already chose worktree or remote is left alone.
  • The worktree starts from your current commit. Claude Code's default worktree starts from origin/HEAD, so an agent never sees your unmerged branch. The mod creates every worktree itself, from the HEAD of the checkout the session works in (a detached HEAD too).
  • A project can set each worktree up. Gitignored files listed in .worktreeinclude are copied in from the main checkout. If the repo has .claude/worktree-setup.ps1 (Windows) or .claude/worktree-setup.sh (elsewhere), the mod gives the worktree a port slot and runs the script in it. See the contract below.
  • What an agent leaves running is stopped. When an agent that ran in a worktree stops, and when a worktree is removed, the mod stops every process whose command line names that worktree, and anything listening on its slot's ports.
  • An agent can't finish while its background work runs. When an agent stops with a background shell or Monitor it started still running, the mod refuses the stop and tells it to end each one with TaskStop, or to say why it should outlive the agent. The agent's report is already delivered by then. A task counts as the agent's when its id appears in the agent's transcript. The agent's second stop always goes through, and the mod cleans up only after a stop it allows.
  • Agents commit their work. Work moves between worktrees only once it is committed. An agent that starts in a worktree is told its branch and asked to commit on it before it finishes. An agent that stops with uncommitted changes (tracked edits, or untracked files .gitignore doesn't cover) is sent back once to commit them, in the same message as any background work. If its second stop still leaves changes, the mod commits them for it with git add -A and git commit --no-verify -m "WIP: uncommitted changes left by <type> <id>", logs it and shows a toast. --no-verify is there so a failing pre-commit hook can't leave the work behind; review the WIP commit before merging it. The mod commits only in an agent's worktree, never in your checkout.
  • The main session is told to merge. At session start the main thread hears that agents work on worktree-* branches, that it should review and merge one (git merge --ff-only for a relay leg) before relying on it, and that it should leave other worktrees alone.
  • A session cleans up after its own agents. After a Bash or PowerShell command with the word merge in it (git merge, gh pr merge), the mod checks, in the background, the worktrees this session created for its agents. It removes one when all of these hold, and logs the reason to the debug log when one doesn't:
  • it is still a worktree, on a worktree-* branch, and not locked;
  • its agent has stopped;
  • git status --porcelain is empty;
  • its branch is merged into the HEAD of the session's checkout: an ancestor of it, or git merge-tree --write-tree HEAD <branch> gives HEAD's own tree (squash and cherry-pick);
  • it is not the session's own checkout.

It stops the worktree's processes and frees its slot, removes the worktree, then runs git branch -d on its branch. A toast names what it removed. -d refuses a squash-merged branch; that branch is kept and /tidy deals with it. The remote is never touched. Only the worktrees this session made count, and the list lives in memory: what a crashed or resumed session left is for /tidy.

  • /tidy cleans up everything else, on demand. It removes the worktree, local branch and remote branch of every merged branch, when nothing would be lost. It fast-forwards a stale local default branch, and stops processes left in worktrees that no longer exist. It names every branch it deleted, and every agent branch nobody merged, as unmerged agent work: <branch> (<n> commits); those are never deleted. /tidy dry-run shows what it would do and changes nothing. Nothing runs it at session start.
SettingDefaultWhat it does
readOnlyAgentsemptyComma-separated agent types that never edit, added to the built-in list.
portBase4000Slot N owns ports portBase + N*10 to portBase + N*10 + 9.

How a worktree is made

The mod creates <main checkout>/.claude/worktrees/<name> on a new branch worktree-<name>, the place and name Claude Code uses. The branch starts at the commit checked out where the session works. Uncommitted changes in your checkout never reach the agent: commit before you start an agent that needs them.

If the main checkout has a .worktreeinclude file (gitignore syntax), the untracked files it matches that .gitignore also ignores are copied into the new worktree. Use it for .env and similar local files.

The .claude/worktree-setup contract

Commit the script to the repo. The mod runs it once, right after it creates a worktree, with the worktree as the working directory and these variables set:

VariableValue
WORKTREE_PATHThe new worktree, absolute.
WORKTREE_NAMEIts name, as Claude Code chose it (agent-… for an agent).
WORKTREE_SLOTIts slot, 1 or higher. Slot 0 is left for the main checkout.
PORT_BASEThe first of its 10 ports. The worktree owns PORT_BASE to PORT_BASE+9.
REPO_ROOTThe main checkout, to copy untracked files from.

A typical script writes ports from PORT_BASE into .env and installs dependencies. It may take up to 10 minutes. If it fails, the worktree is kept, you get a toast, and the agent works in it anyway.

Slots are recorded in <git common dir>/claude-worktrees/slots.json, written under a lock directory beside it so parallel sessions never draw the same slot. A slot is freed when its worktree is removed, or once its folder is gone.

Relays and dependent agents

When one agent continues another's work, or needs your unmerged work:

  1. Work on a feature branch in the main session.
  2. Commit before starting agents that need your changes. Uncommitted changes never reach them.
  3. Each editing agent commits before it hands back, including a WIP commit when it stops with CHECKPOINT:. The mod sees to this; see above.
  4. Between legs, fast-forward your branch to the agent's: git merge --ff-only worktree-<name>. Then start the next leg, which branches from there.

Safety rules

  • It never stops a Claude Code process, the Claude process the mod runs in, or a process whose tree holds either. A shell started in the worktree that runs another Claude session is left alone.
  • It matches a worktree path with separators and, on Windows, case normalised, and only whole path names: agent-a1 never matches agent-a12.
  • It never matches the main checkout. It stops processes only for a worktree being removed, an agent's worktree under .claude/worktrees/, or a worktree folder that no longer exists.
  • A branch is removed only when all of these hold:
  • It is merged: gh pr list --state merged --head <branch> finds a merged PR (squash merges included). Other branches need the PR, since one you just made with git branch foo sits in origin/<default> too. A worktree-* branch also counts as merged, checked in this order:
  • its tip is in origin/<default>;
  • merging it would change nothing: git merge-tree --write-tree origin/<default> <branch> gives origin/<default>'s own tree. This catches squash, cherry-pick and rebase. A conflict, or a different tree, means not merged;
  • only when check 2 can't run (git before 2.38): its tip is in the head commit of one of the last 100 merged PRs, as when its feature branch was squash-merged. Heads missing locally are skipped.
  • It is not the default branch, main, master, or checked out in the main checkout or this session.
  • Its worktree has no uncommitted or untracked changes, is not locked (Claude Code locks an agent's worktree while the agent runs), and its agent has stopped. A worktree the mod created counts as in use until its agent stops, or for 24 hours.
  • Every commit on it is on a remote branch, in the merged PR's head, or in origin/<default>.
  • Worktrees are removed with git worktree remove, never --force. In /tidy, git branch -d comes first; -D only for a branch shown merged as above. The cleanup after a merge uses -d only.
  • On Windows, git worktree remove runs with -c core.longpaths=true. Without it, an ignored path past 260 characters (deep node_modules) makes the remove fail with Filename too long or Directory not empty, after git has already unregistered the worktree and deleted its tracked files. If a remove still fails and git no longer lists the worktree, what is left is ignored files git was deleting: the mod deletes the folder (rd /s /q "\\?\<path>" on Windows, rm -rf elsewhere), runs git worktree prune, and notes it in the debug log. If git still lists the worktree, the failure is reported and nothing is deleted.
  • The remote branch is deleted only when everything on it is in the merged PR or in origin/<default>. Otherwise it is left and reported.
  • The local default branch is fast-forwarded only when it is strictly behind origin/<default> and its checkout has no uncommitted changes, with git merge --ff-only or, when nothing has it checked out, git update-ref.
  • Anything skipped is reported with the reason. /tidy prints them; the cleanup after a merge writes them to the debug log.

Testing

claude plugin validate plugins/worktrees
claude plugin test plugins/worktrees
Source 1 files
hooks/register.ts 1328 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// ---------------------------------------------------------------------------------------------------------------
4// Pure logic: every decision the hooks make, kept apart from the commands that gather facts and act on them.
5// ---------------------------------------------------------------------------------------------------------------
6
7// agent types that only read, so they run in the session's own checkout
8export const READ_ONLY_AGENTS = ['Explore', 'Plan', 'claude-code-guide', 'statusline-setup', 'scout', 'reviewer']
9
10// the Agent tool's own arguments, the ones an updatedInput may carry back
11const AGENT_KEYS = ['description', 'prompt', 'subagent_type', 'model', 'effort', 'name', 'team_name', 'mode', 'run_in_background', 'isolation']
12
13// the configured extra read-only types: a comma-separated string, or a list
14export function readOnlyTypes(extra: unknown): Set<string> {
15  const more = Array.isArray(extra) ? extra.map(String) : typeof extra === 'string' ? extra.split(',') : []
16  return new Set([...READ_ONLY_AGENTS, ...more].map(t => t.trim().toLowerCase()).filter(t => t !== ''))
17}
18
19// An Agent call gets a worktree when nothing chose its isolation, the session is in a git repo, and its type can
20// edit. An unset type is general-purpose, which can.
21export function needsWorktree(input: { isolation?: unknown; subagent_type?: unknown }, inRepo: boolean, readOnly: Set<string>): boolean {
22  if (input.isolation !== undefined && input.isolation !== null) return false
23  if (!inRepo) return false
24  const type = (typeof input.subagent_type === 'string' && input.subagent_type !== '' ? input.subagent_type : 'general-purpose').toLowerCase()
25  // a plugin's agent is `plugin:name`; listing the bare name covers it
26  return !readOnly.has(type) && !readOnly.has(type.slice(type.lastIndexOf(':') + 1))
27}
28
29// the Agent call's arguments with isolation set, and nothing else of the envelope
30export function withWorktree(e: Record<string, unknown>): Record<string, unknown> {
31  const input: Record<string, unknown> = {}
32  for (const key of AGENT_KEYS) if (e[key] !== undefined) input[key] = e[key]
33  input.isolation = 'worktree'
34  return input
35}
36
37// A path as matching compares it: forward slashes, no trailing slash, and on Windows lower case.
38export function normPath(path: string, windows: boolean): string {
39  const slashed = path.replace(/\\/g, '/').replace(/\/+$/, '')
40  return windows ? slashed.toLowerCase() : slashed
41}
42
43// The worktree root a path lies in, when it lies under a `.claude/worktrees/<name>` folder.
44export function worktreeRootOf(path: string): string | undefined {
45  const m = /^(.*[\\/]\.claude[\\/]worktrees[\\/][^\\/]+)/.exec(path)
46  return m?.[1]
47}
48
49const PATH_CHAR = /[A-Za-z0-9._-]/
50
51// True when the command line names this path, or something under it: `.../agent-a1` does not match `.../agent-a12`.
52export function mentions(command: string, path: string, windows: boolean): boolean {
53  const cmd = normPath(command, windows)
54  const target = normPath(path, windows)
55  if (target === '') return false
56  for (let at = cmd.indexOf(target); at !== -1; at = cmd.indexOf(target, at + 1)) {
57    const before = at === 0 ? '' : (cmd[at - 1] ?? '')
58    const after = cmd[at + target.length] ?? ''
59    if ((before === '' || !(PATH_CHAR.test(before) || before === '/')) && (after === '' || !PATH_CHAR.test(after))) return true
60  }
61  return false
62}
63
64// Every `<prefix><name>` path the command line names, `prefix` already normalised and ending in `/`.
65export function worktreeMentions(command: string, prefix: string, windows: boolean): string[] {
66  const cmd = normPath(command, windows)
67  const found = new Set<string>()
68  for (let at = cmd.indexOf(prefix); at !== -1; at = cmd.indexOf(prefix, at + 1)) {
69    const m = /^[^/"'\s;,]+/.exec(cmd.slice(at + prefix.length))
70    if (m) found.add(prefix + m[0])
71  }
72  return [...found]
73}
74
75// a Claude Code process, which this plugin never stops
76export function isClaude(command: string): boolean {
77  return /(^|[\\/\s"'])claude(\.exe)?(["'\s]|$)/i.test(command) || /@anthropic-ai[\\/]claude-code/i.test(command)
78}
79
80export type Proc = { pid: number; ppid: number; pgid?: number; cmd: string }
81export type Listener = { port: number; pid: number }
82// the processes and listening sockets, and the pid of the probe that listed them (a child of this Claude process)
83export type Snapshot = { self: number; procs: Proc[]; listeners: Listener[] }
84
85export function ancestors(pid: number, procs: readonly Proc[]): number[] {
86  const parent = new Map(procs.map(p => [p.pid, p.ppid]))
87  const seen: number[] = []
88  for (let at = parent.get(pid); at !== undefined && at > 0 && !seen.includes(at); at = parent.get(at)) seen.push(at)
89  return seen
90}
91
92export function descendants(pid: number, procs: readonly Proc[]): number[] {
93  const out: number[] = []
94  const queue = [pid]
95  while (queue.length > 0) {
96    const at = queue.shift() as number
97    for (const p of procs) {
98      if (p.ppid === at && p.pid !== pid && !out.includes(p.pid)) {
99        out.push(p.pid)
100        queue.push(p.pid)
101      }
102    }
103  }
104  return out
105}
106
107export type Victims = {
108  // the processes to stop with their trees: none is a descendant of another
109  roots: number[]
110  // every process in those trees
111  tree: number[]
112  // matched, and left alive because its tree holds Claude or the probe
113  spared: number[]
114}
115
116// The processes to stop for these worktree paths and ports: those whose command line names a path, or that listen
117// on a port. Never the probe, its ancestors (this Claude), any Claude process, or a process whose tree holds one.
118export function selectVictims(snapshot: Snapshot, paths: readonly string[], ports: readonly number[], windows: boolean): Victims {
119  const { procs } = snapshot
120  const shielded = new Set([snapshot.self, ...ancestors(snapshot.self, procs)])
121  for (const p of procs) if (isClaude(p.cmd)) shielded.add(p.pid)
122  const listening = new Set(snapshot.listeners.filter(l => ports.includes(l.port)).map(l => l.pid))
123  const matched = procs.filter(
124    p => !shielded.has(p.pid) && p.pid > 4 && (listening.has(p.pid) || paths.some(path => mentions(p.cmd, path, windows))),
125  )
126  const spared: number[] = []
127  const safe = new Set<number>()
128  for (const p of matched) {
129    if (descendants(p.pid, procs).some(d => shielded.has(d))) spared.push(p.pid)
130    else safe.add(p.pid)
131  }
132  const roots = [...safe].filter(pid => !ancestors(pid, procs).some(a => safe.has(a)))
133  const tree = new Set<number>()
134  for (const root of roots) for (const pid of [root, ...descendants(root, procs)]) if (!shielded.has(pid)) tree.add(pid)
135  return { roots: roots.sort((a, b) => a - b), tree: [...tree].sort((a, b) => a - b), spared }
136}
137
138// Process groups safe to signal whole: led by a root, and holding no shielded process.
139export function safeGroups(snapshot: Snapshot, roots: readonly number[]): number[] {
140  const shielded = new Set([snapshot.self, ...ancestors(snapshot.self, snapshot.procs)])
141  for (const p of snapshot.procs) if (isClaude(p.cmd)) shielded.add(p.pid)
142  const shieldedGroups = new Set(snapshot.procs.filter(p => shielded.has(p.pid)).map(p => p.pgid))
143  return roots.filter(pid => {
144    const p = snapshot.procs.find(q => q.pid === pid)
145    return p !== undefined && p.pgid === pid && pid > 1 && !shieldedGroups.has(pid)
146  })
147}
148
149// Windows: one JSON object `{ self, procs: [{ ProcessId, ParentProcessId, CommandLine }], listen: [{ LocalPort, OwningProcess }] }`
150export function parseWindowsSnapshot(json: string): Snapshot {
151  const raw = JSON.parse(json) as { self: number; procs?: unknown; listen?: unknown }
152  const list = (v: unknown): Record<string, unknown>[] => (Array.isArray(v) ? v : v ? [v] : []) as Record<string, unknown>[]
153  return {
154    self: Number(raw.self),
155    procs: list(raw.procs).map(p => ({ pid: Number(p.ProcessId), ppid: Number(p.ParentProcessId), cmd: String(p.CommandLine ?? '') })),
156    listeners: list(raw.listen).map(l => ({ port: Number(l.LocalPort), pid: Number(l.OwningProcess) })),
157  }
158}
159
160// Linux: `SELF <pid>`, then `ps -eo pid=,ppid=,pgid=,args=` lines, then `---`, then `ss -ltnpH` lines
161export function parseLinuxSnapshot(text: string): Snapshot {
162  const lines = text.split('\n')
163  let self = 0
164  const procs: Proc[] = []
165  const listeners: Listener[] = []
166  let inSockets = false
167  for (const line of lines) {
168    const t = line.trim()
169    if (t.startsWith('SELF ')) self = Number(t.slice(5))
170    else if (t === '---') inSockets = true
171    else if (!inSockets) {
172      const m = /^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(t)
173      if (m) procs.push({ pid: Number(m[1]), ppid: Number(m[2]), pgid: Number(m[3]), cmd: m[4] ?? '' })
174    } else if (t !== '') {
175      const cols = t.split(/\s+/)
176      const port = Number(cols[3]?.slice(cols[3].lastIndexOf(':') + 1))
177      for (const pm of t.matchAll(/pid=(\d+)/g)) if (Number.isFinite(port)) listeners.push({ port, pid: Number(pm[1]) })
178    }
179  }
180  return { self, procs, listeners }
181}
182
183// --- port slots ---
184
185export type Slot = { path: string; name: string; at: number }
186export type SlotTable = { slots: Record<string, Slot> }
187
188export const MAX_SLOT = 99
189
190export function portRange(base: number, slot: number): [number, number] {
191  return [base + slot * 10, base + slot * 10 + 9]
192}
193
194export function ports(base: number, slot: number): number[] {
195  const [from] = portRange(base, slot)
196  return Array.from({ length: 10 }, (_, i) => from + i)
197}
198
199export function slotOf(table: SlotTable, path: string, windows: boolean): number | undefined {
200  const key = normPath(path, windows)
201  const hit = Object.entries(table.slots).find(([, s]) => normPath(s.path, windows) === key)
202  return hit === undefined ? undefined : Number(hit[0])
203}
204
205// The path's slot: the one it holds, else the lowest free from 1 (slot 0's ports stay with the main checkout).
206export function claimSlot(table: SlotTable, path: string, name: string, at: number, windows: boolean): { table: SlotTable; slot: number } {
207  const held = slotOf(table, path, windows)
208  if (held !== undefined) return { table, slot: held }
209  let slot = 1
210  while (table.slots[String(slot)] !== undefined) slot++
211  if (slot > MAX_SLOT) throw new Error(`all ${MAX_SLOT} worktree slots are taken`)
212  return { table: { slots: { ...table.slots, [String(slot)]: { path, name, at } } }, slot }
213}
214
215export function releaseSlot(table: SlotTable, path: string, windows: boolean): SlotTable {
216  const slot = slotOf(table, path, windows)
217  if (slot === undefined) return table
218  const slots = { ...table.slots }
219  delete slots[String(slot)]
220  return { slots }
221}
222
223export function parseSlots(text: string | undefined): SlotTable {
224  try {
225    const raw = JSON.parse(text ?? '') as SlotTable
226    return raw && typeof raw.slots === 'object' && raw.slots !== null ? { slots: raw.slots } : { slots: {} }
227  } catch {
228    return { slots: {} }
229  }
230}
231
232// The `.worktreeinclude` files to copy: those the include patterns listed that `git check-ignore` also printed
233// (so .gitignore ignores them), relative, and never reaching outside the checkout.
234export function includedFiles(candidates: readonly string[], checkIgnoreOut: string): string[] {
235  const ignored = new Set(checkIgnoreOut.replace(/\r/g, '').split('\n').filter(Boolean))
236  return candidates.filter(f => ignored.has(f) && !/^([\\/]|[A-Za-z]:)/.test(f) && !f.split(/[\\/]/).includes('..'))
237}
238
239// How WorktreeCreate makes the worktree: reuse its folder, check out its existing branch, or branch from the
240// HEAD commit of the checkout the hook runs in, detached or not. No HEAD (an empty repo): Claude Code's default.
241export type CreatePlan = { kind: 'reuse' } | { kind: 'add-branch' } | { kind: 'add-new'; base: string } | { kind: 'default' }
242
243export function planCreate(f: { pathExists: boolean; branchExists: boolean; head?: string }): CreatePlan {
244  if (f.pathExists) return { kind: 'reuse' }
245  if (f.branchExists) return { kind: 'add-branch' }
246  if (f.head === undefined || !/^[0-9a-f]{7,64}$/i.test(f.head.trim())) return { kind: 'default' }
247  return { kind: 'add-new', base: f.head.trim() }
248}
249
250// What slots.json holds: the slots, and the worktrees this plugin created whose agent has not stopped yet.
251export type State = { slots: SlotTable; active: Record<string, { path: string; at: number }> }
252
253// a worktree created this long ago counts as idle even when no stop was seen (the session died)
254export const ACTIVE_FOR_MS = 24 * 60 * 60_000
255
256export function parseState(text: string | undefined): State {
257  let active: State['active'] = {}
258  try {
259    const raw = JSON.parse(text ?? '') as { active?: State['active'] }
260    if (raw && typeof raw.active === 'object' && raw.active !== null) active = raw.active
261  } catch {
262    // an unreadable file starts over
263  }
264  return { slots: parseSlots(text), active }
265}
266
267export function serializeState(state: State): string {
268  return JSON.stringify({ slots: state.slots.slots, active: state.active }, null, 2) + '\n'
269}
270
271export function markActive(state: State, path: string, at: number, windows: boolean): State {
272  return { ...state, active: { ...state.active, [normPath(path, windows)]: { path, at } } }
273}
274
275export function markDone(state: State, path: string, windows: boolean): State {
276  const active = { ...state.active }
277  delete active[normPath(path, windows)]
278  return { ...state, active }
279}
280
281export function isActive(state: State, path: string, now: number, windows: boolean): boolean {
282  const hit = state.active[normPath(path, windows)]
283  return hit !== undefined && now - hit.at < ACTIVE_FOR_MS
284}
285
286// the lock beside slots.json: a lock older than this was left by a process that died holding it
287export const LOCK_STALE_MS = 10_000
288
289// what a writer does when it finds the lock taken, by the lock's age
290export function lockStep(ageMs: number): 'break' | 'wait' {
291  return ageMs > LOCK_STALE_MS ? 'break' : 'wait'
292}
293
294// --- merged-branch tidy ---
295
296export type Worktree = { path: string; head?: string; branch?: string; locked?: string; prunable: boolean; bare: boolean }
297
298export function parseWorktrees(porcelain: string): Worktree[] {
299  const out: Worktree[] = []
300  let cur: Worktree | undefined
301  for (const line of porcelain.replace(/\r/g, '').split('\n')) {
302    if (line.startsWith('worktree ')) {
303      cur = { path: line.slice(9), prunable: false, bare: false }
304      out.push(cur)
305    } else if (cur === undefined) continue
306    else if (line.startsWith('HEAD ')) cur.head = line.slice(5)
307    else if (line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '')
308    else if (line === 'locked' || line.startsWith('locked ')) cur.locked = line.slice(7) || 'locked'
309    else if (line === 'prunable' || line.startsWith('prunable ')) cur.prunable = true
310    else if (line === 'bare') cur.bare = true
311  }
312  return out
313}
314
315export type BranchFacts = {
316  branch: string
317  // the default branch, main/master, or the branch the session or the main checkout has checked out
318  isProtected: boolean
319  // gh's merged PRs for this head branch; undefined when gh could not answer
320  pr?: { numbers: number[] }
321  // its tip is in origin/<default> (`git branch --merged`); counts as merged without a PR only for worktree-* branches
322  gitMerged: boolean
323  // commits on it that are neither on any remote branch nor in a merged PR's head
324  unpushed: number
325  // worktree-* branches only: commits on it not in origin/<default>
326  ahead?: number
327  // worktree-* branches only: a merged PR whose head commit contains its tip, as when the feature branch an agent
328  // branch was merged into reached the default branch by a squash merge
329  inMergedPr?: number
330  // worktree-* branches only: merging it into origin/<default> (`git merge-tree --write-tree`) gives origin/<default>'s
331  // own tree. undefined when the check could not run (git before 2.38, or another error)
332  contentMerged?: boolean
333  // isActive: this plugin created it for an agent that has not stopped yet
334  worktree?: { path: string; exists: boolean; dirty: boolean; locked?: string; isMain: boolean; isSession: boolean; isActive?: boolean }
335  // origin's branch of the same name; safe when every commit on it is in a merged PR's head or in origin/<default>
336  remote?: { safe: boolean }
337}
338
339export type BranchPlan =
340  | { kind: 'skip'; branch: string; reason: string }
341  | {
342      kind: 'delete'
343      branch: string
344      via: string
345      // the worktree folder to remove; `prune` when its folder is already gone
346      worktreePath?: string
347      prune: boolean
348      // `git branch -D` is allowed after `-d` refuses: only when gh confirmed the merge or the tip is in origin/<default>
349      allowForce: boolean
350      deleteRemote: boolean
351      remoteNote?: string
352    }
353
354export function decideBranch(f: BranchFacts): BranchPlan {
355  const skip = (reason: string): BranchPlan => ({ kind: 'skip', branch: f.branch, reason })
356  if (f.isProtected) return skip('default or checked-out branch')
357  if (f.worktree?.isMain) return skip('checked out in the main checkout')
358  if (f.worktree?.isSession) return skip('this session works in its worktree')
359  const prMerged = f.pr !== undefined && f.pr.numbers.length > 0
360  // a tip in origin/<default> counts as merged without a PR only on the branches agent worktrees get:
361  // a branch made by hand with `git branch foo` sits on the default branch too
362  const agent = f.branch.startsWith('worktree-')
363  const byAncestry = f.gitMerged && agent
364  // agent branches only, in this order: merging it into origin/<default> would change nothing (squash, cherry-pick,
365  // rebase); or, when that check could not run, its tip is in a merged PR's head
366  const byContent = !prMerged && !byAncestry && agent && f.contentMerged === true
367  const bySquash = !prMerged && !byAncestry && agent && f.contentMerged === undefined && f.inMergedPr !== undefined
368  if (!prMerged && !byAncestry && !byContent && !bySquash) {
369    if (agent && f.contentMerged === false) return skip('not merged: merging it into the default branch would change files')
370    const gh = f.pr === undefined ? ' (gh unavailable)' : ''
371    return skip(f.gitMerged ? `no merged PR${gh}; only worktree-* branches count as merged by being in the default branch` : f.pr !== undefined ? 'no merged PR, and not in the default branch' : 'not in the default branch (gh unavailable)')
372  }
373  const via = prMerged
374    ? `PR #${f.pr?.numbers[0]}`
375    : byAncestry
376      ? 'in the default branch'
377      : byContent
378        ? 'content in the default branch'
379        : `in merged PR #${f.inMergedPr}`
380  const also = byContent || bySquash ? ` (${via})` : ''
381  if (f.worktree?.locked !== undefined) return skip(`worktree is locked (${f.worktree.locked})${also}`)
382  if (f.worktree?.isActive) return skip(`its agent has not stopped yet${also}`)
383  if (f.worktree?.dirty) return skip(`worktree has uncommitted changes${also}`)
384  // a squash leaves the branch's own commits on no remote; its content check already proved nothing would be lost
385  if (f.unpushed > 0 && !byContent) return skip(`${f.unpushed} commit${f.unpushed === 1 ? '' : 's'} on no remote and in no merged PR`)
386  return {
387    kind: 'delete',
388    branch: f.branch,
389    via,
390    worktreePath: f.worktree?.exists ? f.worktree.path : undefined,
391    prune: f.worktree !== undefined && !f.worktree.exists,
392    allowForce: true,
393    deleteRemote: f.remote?.safe === true,
394    remoteNote: f.remote !== undefined && !f.remote.safe ? 'remote branch has commits outside the merge; left it' : undefined,
395  }
396}
397
398// The merged PR whose head commit is, or contains, `tip`. `present`: the heads that exist locally; `contains`: those
399// of them `tip` is an ancestor of. A head missing locally (its branch deleted, never fetched) proves nothing.
400export type MergedHead = { number: number; head: string }
401export function findMergedPr(tip: string, heads: readonly MergedHead[], present: ReadonlySet<string>, contains: ReadonlySet<string>): number | undefined {
402  return heads.find(h => present.has(h.head) && (h.head === tip || contains.has(h.head)))?.number
403}
404
405// Local default branch fast-forward: only when strictly behind origin's, and not checked out with changes.
406export type MainSync =
407  | { kind: 'none'; reason?: string }
408  | { kind: 'update-ref'; from: string; to: string }
409  | { kind: 'merge-ff'; worktree: string; to: string }
410
411export function decideMainSync(f: {
412  branch: string
413  local?: string
414  remote?: string
415  // local is an ancestor of remote
416  behind: boolean
417  checkedOut?: { path: string; dirty: boolean }
418}): MainSync {
419  if (f.local === undefined || f.remote === undefined || f.local === f.remote) return { kind: 'none' }
420  if (!f.behind) return { kind: 'none', reason: `${f.branch} has commits not on origin/${f.branch}; not fast-forwarded` }
421  if (f.checkedOut === undefined) return { kind: 'update-ref', from: f.local, to: f.remote }
422  if (f.checkedOut.dirty) return { kind: 'none', reason: `${f.branch} is behind origin but its checkout has uncommitted changes; not fast-forwarded` }
423  return { kind: 'merge-ff', worktree: f.checkedOut.path, to: f.remote }
424}
425
426export function describePlan(plan: BranchPlan): string {
427  if (plan.kind === 'skip') return `skip ${plan.branch}: ${plan.reason}`
428  const parts = [plan.worktreePath ? `remove worktree ${plan.worktreePath}` : plan.prune ? 'prune its missing worktree' : '', `delete branch`]
429  if (plan.deleteRemote) parts.push('delete origin/' + plan.branch)
430  if (plan.remoteNote) parts.push(plan.remoteNote)
431  return `${plan.branch} (${plan.via}): ${parts.filter(Boolean).join('; ')}`
432}
433
434// --- self-cleanup after a merge ---
435
436// What the sweep knows of a worktree this session's WorktreeCreate made or reused. `branch` and `locked` come
437// from `git worktree list`; `dirty` and `merged` are only gathered for a registered worktree-* branch.
438export type SelfCleanFacts = {
439  path: string
440  registered: boolean
441  branch?: string
442  locked?: string
443  // the session's own checkout
444  isSession: boolean
445  // its agent has not stopped yet
446  active: boolean
447  // `git status --porcelain` printed something, or failed
448  dirty: boolean
449  // its branch is in the session checkout's HEAD: an ancestor, or merging it gives HEAD's own tree
450  merged: boolean
451}
452
453// `forget`: the path drops out of the session's tracking, as it can never qualify again
454export type SelfCleanPlan = { kind: 'remove'; path: string; branch: string } | { kind: 'skip'; path: string; reason: string; forget: boolean }
455
456export function decideSelfClean(f: SelfCleanFacts): SelfCleanPlan {
457  const skip = (reason: string, forget = false): SelfCleanPlan => ({ kind: 'skip', path: f.path, reason, forget })
458  if (f.isSession) return skip('the session works in it', true)
459  if (!f.registered) return skip('no longer a registered worktree', true)
460  if (f.branch === undefined || !f.branch.startsWith('worktree-')) return skip(`not on a worktree-* branch (${f.branch ?? 'detached'})`)
461  if (f.locked !== undefined) return skip(`locked (${f.locked})`)
462  if (f.active) return skip('its agent has not stopped yet')
463  if (f.dirty) return skip('uncommitted changes')
464  if (!f.merged) return skip(`${f.branch} is not merged into the session's HEAD`)
465  return { kind: 'remove', path: f.path, branch: f.branch }
466}
467
468// ---------------------------------------------------------------------------------------------------------------
469// Effects: commands on the host.
470// ---------------------------------------------------------------------------------------------------------------
471
472type $ = EngineInterface
473
474// Windows: processes with command lines and, when asked (it is the slow part), the listening sockets
475export function windowsProbe(withPorts: boolean): string {
476  return [
477    '$ErrorActionPreference = "SilentlyContinue"',
478    '$p = @(Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, CommandLine)',
479    withPorts ? '$l = @(Get-NetTCPConnection -State Listen | Select-Object LocalPort, OwningProcess)' : '$l = @()',
480    '[pscustomobject]@{ self = $PID; procs = $p; listen = $l } | ConvertTo-Json -Compress -Depth 3',
481  ].join('; ')
482}
483
484export const LINUX_PROBE = 'echo "SELF $$"; ps -eo pid=,ppid=,pgid=,args=; echo ---; ss -ltnpH 2>/dev/null || true'
485
486// --- background work an agent leaves running ---
487
488export type BackgroundTask = { id: string; type: string; status: string; description: string; command?: string }
489
490const FINISHED = new Set(['completed', 'failed', 'killed', 'stopped', 'cancelled', 'canceled', 'done', 'error'])
491
492// shells and monitors still going; subagents and other task types are not this agent's to stop
493export function liveWork(tasks: readonly BackgroundTask[] | undefined): BackgroundTask[] {
494  return (tasks ?? []).filter(t => (t.type === 'shell' || t.type === 'monitor') && !FINISHED.has(t.status))
495}
496
497export type StopDecision =
498  | { kind: 'allow'; owned: BackgroundTask[]; commit: boolean }
499  | { kind: 'block'; owned: BackgroundTask[]; reason: string }
500
501// The agent's worktree with uncommitted changes: tracked edits or untracked files .gitignore does not cover.
502export type DirtyWorktree = { path: string; branch: string; files: number }
503
504const REPORT_SENT = "Your report was already delivered; don't send it again."
505
506// `inTranscript`: the task ids found in the agent's own transcript, undefined when it could not be read.
507// The task list may cover the whole session; an id in the agent's transcript is one its own tool started.
508// `dirty`: the agent's worktree when it has uncommitted changes. An allowed stop with a dirty worktree is
509// committed for the agent (`commit`).
510export function decideStop(f: {
511  stopHookActive: boolean
512  tasks: readonly BackgroundTask[] | undefined
513  inTranscript: ReadonlySet<string> | undefined
514  dirty?: DirtyWorktree
515}): StopDecision {
516  const owned = f.inTranscript === undefined ? [] : liveWork(f.tasks).filter(t => f.inTranscript!.has(t.id))
517  const dirty = f.dirty !== undefined && f.dirty.files > 0 ? f.dirty : undefined
518  // a stop that follows our own block goes through, so it cannot loop
519  if ((owned.length === 0 && dirty === undefined) || f.stopHookActive) return { kind: 'allow', owned, commit: dirty !== undefined }
520  const parts: string[] = []
521  if (owned.length > 0) {
522    const list = owned.map(t => `${t.id} ${(t.command ?? t.description).split('\n')[0]}`).join('\n')
523    parts.push(`You still have background work running:\n${list}\nStop each with TaskStop, unless it is meant to outlive you; then say so in one line.`)
524  }
525  if (dirty !== undefined) {
526    parts.push(
527      `Your worktree \`${dirty.path}\` has uncommitted changes (${dirty.files} file${dirty.files === 1 ? '' : 's'}). ` +
528        `Commit them on \`${dirty.branch}\` with a message that says what they are, then finish.`,
529    )
530  }
531  return { kind: 'block', owned, reason: `${parts.join('\n')} ${REPORT_SENT}` }
532}
533
534// Lines of `git status --porcelain`: one per changed or untracked file.
535export function countChanges(porcelain: string): number {
536  return porcelain.replace(/\r/g, '').split('\n').filter(l => l.trim() !== '').length
537}
538
539export const WIP_MESSAGE = (agentType: string, agentId: string) => `WIP: uncommitted changes left by ${agentType} ${agentId}`
540
541// Told to an editing agent when it starts in a worktree.
542export function startContext(branch: string): string {
543  return (
544    `You are working in a git worktree on branch \`${branch}\`. Commit your changes on that branch before you finish, ` +
545    "with a message that says what they are; uncommitted work doesn't reach the main session. Don't push."
546  )
547}
548
549// Told to the main thread at session start.
550export const SESSION_CONTEXT =
551  "Editing agents work in git worktrees on `worktree-*` branches; before relying on an agent's work or starting an agent that " +
552  'depends on it, review and merge its branch: `git merge --ff-only worktree-<name>` for a relay leg, a normal merge otherwise. ' +
553  "Once you merge it, that agent's worktree and branch are removed for you; leave other worktrees alone (`/tidy` is for those)."
554
555// `worktree-*` branches with commits outside origin's default branch and no merged PR: agent work nobody merged yet.
556export function unmergedAgentWork(facts: readonly BranchFacts[]): string[] {
557  return facts
558    .filter(f => f.branch.startsWith('worktree-') && !f.isProtected && !f.gitMerged && (f.pr?.numbers.length ?? 0) === 0)
559    .filter(f => f.contentMerged === false || (f.contentMerged === undefined && f.inMergedPr === undefined))
560    .filter(f => !f.worktree?.isMain && !f.worktree?.isSession && !f.worktree?.isActive && (f.ahead ?? 0) > 0)
561    .map(f => `unmerged agent work: ${f.branch} (${f.ahead} commit${f.ahead === 1 ? '' : 's'})`)
562}
563
564async function isWindows($: $): Promise<boolean> {
565  return (await $.env.get('OS')) === 'Windows_NT'
566}
567
568async function run($: $, argv: string[], cwd?: string, timeoutMs?: number) {
569  try {
570    return await $.process.run(argv, { cwd, timeoutMs })
571  } catch (error) {
572    return { exitCode: -1, stdout: '', stderr: String(error), isStdoutTruncated: false, isStderrTruncated: false }
573  }
574}
575
576async function git($: $, cwd: string | undefined, ...args: string[]) {
577  const r = await run($, ['git', ...args], cwd, 120_000)
578  return { ok: r.exitCode === 0, out: r.stdout.replace(/\r/g, '').trim(), err: r.stderr.replace(/\r/g, '').trim() }
579}
580
581// Which of `ids` the transcript mentions, without reading it into memory: transcripts grow past what $.fs.read takes.
582// undefined when the file cannot be searched.
583async function idsInTranscript($: $, path: string, ids: readonly string[]): Promise<Set<string> | undefined> {
584  if (ids.length === 0) return new Set()
585  if (!path || !(await $.fs.exists(path).catch(() => false))) return undefined
586  const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
587  const dir = cut > 0 ? path.slice(0, cut) : cut === 0 ? '/' : undefined
588  const r = await run($, ['git', 'grep', '--no-index', '-h', '-o', '-F', ...ids.flatMap(id => ['-e', id]), '--', path.slice(cut + 1)], dir, 30_000)
589  if (r.exitCode === 1) return new Set()
590  if (r.exitCode === 0) return new Set(r.stdout.replace(/\r/g, '').split('\n').filter(Boolean))
591  // git could not search it: a transcript small enough is read whole
592  try {
593    const text = await $.fs.read(path)
594    return new Set(ids.filter(id => text.includes(id)))
595  } catch {
596    return undefined
597  }
598}
599
600async function snapshot($: $, windows: boolean, withPorts: boolean): Promise<Snapshot | undefined> {
601  const r = windows
602    ? await run($, ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', windowsProbe(withPorts)], undefined, 60_000)
603    : await run($, ['sh', '-c', LINUX_PROBE], undefined, 30_000)
604  if (r.exitCode !== 0 || r.stdout.trim() === '') return undefined
605  try {
606    return windows ? parseWindowsSnapshot(r.stdout) : parseLinuxSnapshot(r.stdout)
607  } catch {
608    return undefined
609  }
610}
611
612// Stops what runs in these worktrees or listens on these ports. Answers what it stopped, for the report.
613async function stopProcesses($: $, windows: boolean, paths: string[], portList: number[], dryRun = false): Promise<string[]> {
614  if (paths.length === 0 && portList.length === 0) return []
615  const snap = await snapshot($, windows, portList.length > 0)
616  if (snap === undefined) return ['could not list processes; stopped nothing']
617  const victims = selectVictims(snap, paths, portList, windows)
618  const label = (pid: number) => `${pid} ${(snap.procs.find(p => p.pid === pid)?.cmd ?? '').slice(0, 80)}`
619  const notes = victims.spared.map(pid => `spared ${label(pid)}: its tree holds a Claude process`)
620  if (victims.roots.length === 0) return notes
621  if (dryRun) return [...notes, ...victims.roots.map(pid => `would stop ${label(pid)}`)]
622  if (windows) {
623    for (const pid of victims.roots) await run($, ['taskkill', '/T', '/F', '/PID', String(pid)])
624  } else {
625    const groups = safeGroups(snap, victims.roots)
626    for (const g of groups) await run($, ['kill', '-TERM', '--', `-${g}`])
627    await run($, ['kill', '-TERM', ...victims.tree.map(String)])
628    await $.clock.sleep(1500)
629    for (const g of groups) await run($, ['kill', '-KILL', '--', `-${g}`])
630    await run($, ['kill', '-KILL', ...victims.tree.map(String)])
631  }
632  return [...notes, ...victims.roots.map(pid => `stopped ${label(pid)}`)]
633}
634
635type Repo = { top: string; common: string; mainRoot: string }
636
637// The checkout `cwd` is in, its git common dir, and the main checkout; undefined outside a non-bare repo.
638async function repoAt($: $, cwd?: string): Promise<Repo | undefined> {
639  const top = await git($, cwd, 'rev-parse', '--show-toplevel')
640  if (!top.ok || top.out === '') return undefined
641  const common = await git($, cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir')
642  if (!common.ok || !/[\\/]\.git$/.test(common.out)) return undefined
643  return { top: top.out, common: common.out, mainRoot: common.out.replace(/[\\/]\.git$/, '') }
644}
645
646type AgentWorktree = { repo: Repo; path: string; branch: string }
647
648// The worktree an agent runs in, when the plugin can identify it: the `.claude/worktrees/<name>` folder `cwd` lies
649// in, or `agent-<id>` there. Never the main checkout or the worktree the session itself works in, and only a folder
650// that is the top of its own checkout with a branch checked out.
651async function agentWorktree($: $, cwd: string, agentId: string): Promise<AgentWorktree | undefined> {
652  const repo = await repoAt($, cwd)
653  if (repo === undefined) return undefined
654  const windows = await isWindows($)
655  const prefix = normPath(`${repo.mainRoot}/.claude/worktrees`, windows) + '/'
656  const own = worktreeRootOf(await $.session.cwd())
657  for (const path of [worktreeRootOf(cwd), `${repo.mainRoot}/.claude/worktrees/agent-${agentId}`]) {
658    if (path === undefined) continue
659    const n = normPath(path, windows)
660    if (!n.startsWith(prefix) || n === normPath(repo.mainRoot, windows)) continue
661    if (own !== undefined && normPath(own, windows) === n) continue
662    if (!(await $.fs.exists(path))) continue
663    const top = await git($, path, 'rev-parse', '--show-toplevel')
664    if (!top.ok || normPath(top.out, windows) !== n) continue
665    const branch = await git($, path, 'branch', '--show-current')
666    if (!branch.ok || branch.out === '') continue
667    return { repo, path, branch: branch.out }
668  }
669  return undefined
670}
671
672// The worktree's uncommitted changes, undefined when it is clean or git cannot say.
673async function dirtyOf($: $, wt: AgentWorktree): Promise<DirtyWorktree | undefined> {
674  const status = await git($, wt.path, 'status', '--porcelain', '--untracked-files=all')
675  const files = status.ok ? countChanges(status.out) : 0
676  return files > 0 ? { path: wt.path, branch: wt.branch, files } : undefined
677}
678
679// Commits what an agent left uncommitted, so it reaches the main session. --no-verify: a failing pre-commit hook
680// must not leave the work behind.
681async function commitLeftovers($: $, wt: AgentWorktree, dirty: DirtyWorktree, agentType: string, agentId: string): Promise<void> {
682  const added = await git($, wt.path, 'add', '-A')
683  const done = added.ok ? await git($, wt.path, 'commit', '--no-verify', '--quiet', '-m', WIP_MESSAGE(agentType, agentId)) : added
684  const what = `${dirty.files} uncommitted file${dirty.files === 1 ? '' : 's'} left by ${agentType} ${agentId} in ${wt.path}`
685  if (done.ok) {
686    await $.ui.log(`worktrees: committed ${what} on ${wt.branch}`, { to: 'debug' })
687    await $.ui.toast(`worktrees: ${agentType} left changes uncommitted; committed them as WIP on ${wt.branch}`)
688  } else {
689    await $.ui.log(`worktrees: could not commit ${what}: ${done.err}`, { to: 'debug' })
690    await $.ui.toast(`worktrees: ${agentType} left uncommitted changes in ${wt.path}; committing them failed`)
691  }
692}
693
694const stateFile = (repo: Repo) => `${repo.common}/claude-worktrees/slots.json`
695const lockDir = (repo: Repo) => `${repo.common}/claude-worktrees/slots.lock`
696
697async function readState($: $, repo: Repo): Promise<State> {
698  return parseState((await $.fs.exists(stateFile(repo))) ? ((await $.fs.read(stateFile(repo))) as string) : undefined)
699}
700
701// Creating a directory either succeeds or finds it there, atomically: the lock across sessions.
702async function takeLock($: $, repo: Repo, windows: boolean): Promise<boolean> {
703  const dir = lockDir(repo)
704  const r = windows
705    ? await run($, ['cmd.exe', '/d', '/c', 'mkdir', dir.replace(/\//g, '\\')])
706    : await run($, ['sh', '-c', 'mkdir -p "$(dirname "$1")" && mkdir "$1"', 'sh', dir])
707  return r.exitCode === 0
708}
709
710async function dropLock($: $, repo: Repo, windows: boolean): Promise<void> {
711  const dir = lockDir(repo)
712  await (windows ? run($, ['cmd.exe', '/d', '/c', 'rmdir', dir.replace(/\//g, '\\')]) : run($, ['rmdir', dir]))
713}
714
715// State writes run one at a time: in this process by a promise chain, across sessions by the lock directory,
716// with slots.json read again under the lock, so parallel agents never draw the same slot.
717let stateChain: Promise<unknown> = Promise.resolve()
718function withState<T>($: $, repo: Repo, fn: (state: State) => Promise<{ state: State; value: T }>): Promise<T> {
719  const step = stateChain.then(async () => {
720    const windows = await isWindows($)
721    for (let tries = 0; !(await takeLock($, repo, windows)); tries++) {
722      const stat = await $.fs.stat(lockDir(repo)).catch(() => undefined)
723      if (stat !== undefined && lockStep((await $.clock.now()) - stat.mtimeMs) === 'break') await dropLock($, repo, windows)
724      else if (tries > 200) throw new Error('slots.lock stayed taken')
725      else await $.clock.sleep(100)
726    }
727    try {
728      const { state, value } = await fn(await readState($, repo))
729      await $.fs.write(stateFile(repo), serializeState(state))
730      return value
731    } finally {
732      await dropLock($, repo, windows)
733    }
734  })
735  stateChain = step.catch(() => undefined)
736  return step
737}
738
739// Copies the gitignored files `.worktreeinclude` names from the main checkout into a new worktree. Answers a
740// note for the debug log, or '' when the repo has no such file.
741async function copyIncluded($: $, repo: Repo, path: string, windows: boolean): Promise<string> {
742  const include = `${repo.mainRoot}/.worktreeinclude`
743  if (!(await $.fs.exists(include))) return ''
744  // untracked files the include patterns match, kept only where .gitignore ignores them too
745  const listed = await git($, repo.mainRoot, 'ls-files', '--others', '--ignored', `--exclude-from=${include}`)
746  const candidates = listed.out.split('\n').filter(Boolean)
747  if (candidates.length === 0) return '.worktreeinclude matched no files'
748  const checked = await $.process.run(['git', 'check-ignore', '--stdin'], { cwd: repo.mainRoot, stdin: candidates.join('\n') + '\n' })
749  const files = includedFiles(candidates, checked.stdout)
750  if (files.length === 0) return '.worktreeinclude matched no ignored files'
751  const r = windows
752    ? await $.process.run(
753        [
754          'powershell.exe',
755          '-NoProfile',
756          '-NonInteractive',
757          '-Command',
758          '$src = $env:WT_SRC; $dst = $env:WT_DST; $input | ForEach-Object { if ($_) { $t = Join-Path $dst $_; New-Item -ItemType Directory -Force (Split-Path $t) | Out-Null; Copy-Item -LiteralPath (Join-Path $src $_) -Destination $t -Force } }',
759        ],
760        { env: { WT_SRC: repo.mainRoot, WT_DST: path }, stdin: files.join('\n') + '\n', timeoutMs: 600_000 },
761      )
762    : await $.process.run(
763        ['sh', '-c', 'while IFS= read -r f; do [ -n "$f" ] || continue; mkdir -p "$2/$(dirname "$f")" && cp -p "$1/$f" "$2/$f"; done', 'sh', repo.mainRoot, path],
764        { stdin: files.join('\n') + '\n', timeoutMs: 600_000 },
765      )
766  return r.exitCode === 0 ? `copied ${files.length} .worktreeinclude file(s)` : `.worktreeinclude copy failed: ${r.stderr.slice(0, 300)}`
767}
768
769function portBaseOf(options: Record<string, unknown>): number {
770  const n = Number(options.portBase ?? 4000)
771  return Number.isFinite(n) && n >= 1024 && n <= 64_000 ? Math.floor(n) : 4000
772}
773
774// Stops a worktree's processes and the ones on its slot's ports. The slot is released when `release` is set,
775// or when the worktree folder is gone.
776async function cleanWorktree($: $, repo: Repo, path: string, base: number, release: boolean): Promise<string[]> {
777  const windows = await isWindows($)
778  const state = await readState($, repo)
779  const slot = slotOf(state.slots, path, windows)
780  const notes = await stopProcesses($, windows, [path], slot === undefined ? [] : ports(base, slot))
781  const freeSlot = slot !== undefined && (release || !(await $.fs.exists(path)))
782  if (freeSlot || state.active[normPath(path, windows)] !== undefined) {
783    await withState($, repo, async s => ({
784      state: markDone(freeSlot ? { ...s, slots: releaseSlot(s.slots, path, windows) } : s, path, windows),
785      value: undefined,
786    }))
787  }
788  return notes
789}
790
791// Merging `branch` into `into` would change nothing: `git merge-tree --write-tree` gives `into`'s own tree. undefined
792// when the check could not run (git before 2.38, or another error).
793async function mergesToSameTree($: $, cwd: string, into: string, intoTree: string, branch: string): Promise<boolean | undefined> {
794  const m = await run($, ['git', 'merge-tree', '--write-tree', into, branch], cwd, 60_000)
795  if (m.exitCode === 0) return m.stdout.replace(/\r/g, '').split('\n')[0]?.trim() === intoTree
796  if (m.exitCode === 1) return false
797  return undefined
798}
799
800// `git worktree remove`, never --force. On Windows an ignored path past MAX_PATH (node_modules) makes a plain remove
801// fail after git has already unregistered the worktree and deleted its tracked files, so core.longpaths is always on,
802// and a failure that leaves the worktree unregistered is finished here: what is left is ignored files git was deleting.
803async function removeWorktree($: $, cwd: string, path: string, windows: boolean): Promise<{ ok: boolean; err: string; note?: string }> {
804  const removed = await run($, ['git', ...(windows ? ['-c', 'core.longpaths=true'] : []), 'worktree', 'remove', path], cwd, 600_000)
805  if (removed.exitCode === 0) return { ok: true, err: '' }
806  const err = removed.stderr.replace(/\r/g, '').trim().split('\n')[0] ?? ''
807  const listed = await git($, cwd, 'worktree', 'list', '--porcelain')
808  const key = normPath(path, windows)
809  if (!listed.ok || parseWorktrees(listed.out).some(w => normPath(w.path, windows) === key)) return { ok: false, err }
810  // the \\?\ prefix lifts MAX_PATH for rd
811  const deleted = windows
812    ? await run($, ['cmd.exe', '/d', '/c', 'rd', '/s', '/q', `\\\\?\\${path.replace(/\//g, '\\')}`], undefined, 600_000)
813    : await run($, ['rm', '-rf', '--', path], undefined, 600_000)
814  await git($, cwd, 'worktree', 'prune')
815  if (await $.fs.exists(path)) return { ok: false, err: `${err}; git unregistered it, and deleting the rest failed: ${deleted.stderr.trim().split('\n')[0] ?? ''}` }
816  const note = `git worktree remove ${path} failed (${err}) after unregistering it; deleted the ignored files left behind`
817  await $.ui.log(`worktrees: ${note}`, { to: 'debug' })
818  return { ok: true, err: '', note }
819}
820
821// --- self-cleanup ---
822
823// Removes the worktrees in `tracked` (normalised path to path) whose agent is done, that are clean, and whose branch
824// is merged into the HEAD of the session's own checkout; then their branch with `git branch -d`. Answers what it
825// removed, for the toast. Removed paths, and paths that can never qualify, drop out of `tracked`.
826async function selfClean($: $, tracked: Map<string, string>, base: number): Promise<string[]> {
827  if (tracked.size === 0) return []
828  const windows = await isWindows($)
829  const cwd = await $.session.cwd()
830  const repo = await repoAt($, cwd)
831  if (repo === undefined) return []
832  const log = (line: string) => $.ui.log(`worktrees: ${line}`, { to: 'debug' })
833  const worktrees = parseWorktrees((await git($, cwd, 'worktree', 'list', '--porcelain')).out)
834  const headTree = await git($, cwd, 'rev-parse', 'HEAD^{tree}')
835  const state = await readState($, repo)
836  const now = await $.clock.now()
837  const removed: string[] = []
838  for (const [key, path] of [...tracked]) {
839    const wt = worktrees.find(w => normPath(w.path, windows) === key)
840    const branch = wt?.branch
841    let dirty = false
842    let merged = false
843    if (wt !== undefined && branch?.startsWith('worktree-')) {
844      const status = await git($, path, 'status', '--porcelain')
845      dirty = !status.ok || status.out !== ''
846      if (!dirty) {
847        merged = (await git($, cwd, 'merge-base', '--is-ancestor', branch, 'HEAD')).ok
848        if (!merged && headTree.ok && headTree.out !== '') merged = (await mergesToSameTree($, cwd, 'HEAD', headTree.out, branch)) === true
849      }
850    }
851    const plan = decideSelfClean({
852      path,
853      registered: wt !== undefined,
854      branch,
855      locked: wt?.locked,
856      isSession: key === normPath(repo.top, windows),
857      active: isActive(state, path, now, windows),
858      dirty,
859      merged,
860    })
861    if (plan.kind === 'skip') {
862      await log(`self-clean skipped ${path}: ${plan.reason}`)
863      if (plan.forget) tracked.delete(key)
864      continue
865    }
866    const notes = await cleanWorktree($, repo, path, base, true)
867    if (notes.length > 0) await log(notes.join('\n'))
868    const done = await removeWorktree($, cwd, path, windows)
869    if (!done.ok) {
870      await log(`self-clean could not remove ${path}: ${done.err}`)
871      continue
872    }
873    tracked.delete(key)
874    const deleted = await git($, cwd, 'branch', '-d', plan.branch)
875    if (deleted.ok) removed.push(plan.branch)
876    else {
877      await log(`self-clean removed ${path}; git branch -d ${plan.branch} refused: ${deleted.err.split('\n')[0]}`)
878      removed.push(`${plan.branch}'s worktree (branch kept: git branch -d refused)`)
879    }
880  }
881  return removed
882}
883
884// a session's tracked worktrees (normalised path to path) and its sweeps, which run one at a time
885type Sweeps = { tracked: Map<string, string>; chain: Promise<void> }
886
887function sweep($: $, s: Sweeps, base: number): Promise<void> {
888  s.chain = s.chain
889    .then(async () => {
890      const removed = await selfClean($, s.tracked, base)
891      if (removed.length > 0) await $.ui.toast(`worktrees: removed merged ${removed.join(', ')}`)
892    })
893    .catch(error => $.ui.log(`worktrees: self-clean failed: ${String(error)}`, { to: 'debug' }))
894  return s.chain
895}
896
897// --- tidy ---
898
899type TidyReport = { plans: BranchPlan[]; lines: string[]; acted: boolean; removed: string[]; unmerged: string[] }
900
901type Facts = { facts: BranchFacts[]; worktrees: Worktree[]; sync: MainSync; defaultBranch: string }
902
903async function gatherFacts($: $, repo: Repo, windows: boolean): Promise<Facts> {
904  const head = await git($, repo.top, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD')
905  const defaultBranch = head.ok ? head.out.replace(/^origin\//, '') : 'main'
906  const state = await readState($, repo)
907  const now = await $.clock.now()
908  const current = (await git($, repo.top, 'branch', '--show-current')).out
909  const worktrees = parseWorktrees((await git($, repo.top, 'worktree', 'list', '--porcelain')).out)
910  const main = worktrees[0]
911  const locals = (await git($, repo.top, 'for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/heads')).out
912    .split('\n')
913    .filter(Boolean)
914    .map(l => l.split(' ') as [string, string])
915  const remotes = new Map(
916    (await git($, repo.top, 'for-each-ref', '--format=%(refname:short) %(objectname)', 'refs/remotes/origin')).out
917      .split('\n')
918      .filter(Boolean)
919      .map(l => l.split(' ') as [string, string]),
920  )
921  const mergedRef = remotes.has(`origin/${defaultBranch}`) ? `origin/${defaultBranch}` : defaultBranch
922  const gitMerged = new Set((await git($, repo.top, 'branch', '--merged', mergedRef, '--format=%(refname:short)')).out.split('\n'))
923  const hasGh = (await run($, ['gh', 'auth', 'status'], repo.top)).exitCode === 0
924  const isAncestor = async (a: string, b: string) => (await git($, repo.top, 'merge-base', '--is-ancestor', a, b)).ok
925
926  // origin/<default>'s tree, for the content check of agent branches
927  const treeOf = await git($, repo.top, 'rev-parse', `${mergedRef}^{tree}`)
928  const mainTree = treeOf.ok && treeOf.out !== '' ? treeOf.out : undefined
929
930  // recently merged PRs' head commits, for agent branches merged into a branch whose PR was squash-merged:
931  // one gh call per run, the heads present locally in one cat-file, and the commits they hold outside the default branch
932  let mergedHeads: MergedHead[] = []
933  const present = new Set<string>()
934  let inHeads = new Set<string>()
935  if (hasGh && locals.some(([b]) => b.startsWith('worktree-'))) {
936    const r = await run($, ['gh', 'pr', 'list', '--state', 'merged', '--limit', '100', '--json', 'number,headRefOid,headRefName'], repo.top, 60_000)
937    try {
938      if (r.exitCode === 0) mergedHeads = (JSON.parse(r.stdout) as { number: number; headRefOid: string }[]).map(p => ({ number: p.number, head: p.headRefOid }))
939    } catch {
940      mergedHeads = []
941    }
942    if (mergedHeads.length > 0) {
943      const check = await $.process.run(['git', 'cat-file', '--batch-check=%(objectname) %(objecttype)'], {
944        cwd: repo.top,
945        stdin: mergedHeads.map(h => h.head).join('\n') + '\n',
946        timeoutMs: 60_000,
947      })
948      for (const line of check.stdout.replace(/\r/g, '').split('\n')) {
949        const [sha, type] = line.split(' ')
950        if (sha && type === 'commit') present.add(sha)
951      }
952      if (present.size > 0) {
953        const argv = ['git', 'rev-list', ...present, '--not', mergedRef]
954        const listed = await run($, argv, repo.top, 120_000)
955        if (listed.exitCode === 0) inHeads = new Set(listed.stdout.replace(/\r/g, '').split('\n').filter(Boolean))
956      }
957    }
958  }
959
960  // the local default branch, fast-forwarded when strictly behind origin's
961  const localMain = locals.find(([b]) => b === defaultBranch)?.[1]
962  const remoteMain = remotes.get(`origin/${defaultBranch}`)
963  const mainWt = worktrees.find(w => w.branch === defaultBranch)
964  const mainStatus = mainWt !== undefined && (await $.fs.exists(mainWt.path)) ? await git($, mainWt.path, 'status', '--porcelain', '--untracked-files=no') : undefined
965  const sync = decideMainSync({
966    branch: defaultBranch,
967    local: localMain,
968    remote: remoteMain,
969    behind: localMain !== undefined && remoteMain !== undefined && (await isAncestor(localMain, remoteMain)),
970    checkedOut: mainWt === undefined ? undefined : { path: mainWt.path, dirty: mainStatus === undefined || !mainStatus.ok || mainStatus.out !== '' },
971  })
972
973  const facts: BranchFacts[] = []
974  for (const [branch, tip] of locals) {
975    const wt = worktrees.find(w => w.branch === branch)
976    const isProtected = [defaultBranch, 'main', 'master', current, main?.branch].includes(branch)
977    let pr: BranchFacts['pr']
978    let heads: string[] = []
979    if (hasGh && !isProtected) {
980      const r = await run($, ['gh', 'pr', 'list', '--state', 'merged', '--head', branch, '--json', 'number,headRefOid', '--limit', '10'], repo.top, 60_000)
981      if (r.exitCode === 0) {
982        try {
983          const list = JSON.parse(r.stdout) as { number: number; headRefOid: string }[]
984          pr = { numbers: list.map(p => p.number) }
985          heads = list.map(p => p.headRefOid)
986        } catch {
987          pr = undefined
988        }
989      }
990    }
991    let unpushed = 0
992    let inMergedPr: number | undefined
993    let contentMerged: boolean | undefined
994    if (branch.startsWith('worktree-') && !isProtected && !gitMerged.has(branch) && mainTree !== undefined) {
995      contentMerged = await mergesToSameTree($, repo.top, mergedRef, mainTree, branch)
996    }
997    if (!isProtected) {
998      let inMergedHead = false
999      if (contentMerged === undefined && branch.startsWith('worktree-') && !(pr?.numbers.length) && !gitMerged.has(branch) && (inHeads.has(tip) || present.has(tip))) {
1000        const contains = new Set<string>()
1001        for (const h of mergedHeads) if (present.has(h.head) && h.head !== tip && (await isAncestor(tip, h.head))) contains.add(h.head)
1002        inMergedPr = findMergedPr(tip, mergedHeads, present, contains)
1003        if (inMergedPr !== undefined) {
1004          inMergedHead = true
1005          heads = [...heads, mergedHeads.find(h => h.number === inMergedPr)!.head]
1006        }
1007      }
1008      for (const h of heads) if (h === tip || (await isAncestor(tip, h))) inMergedHead = true
1009      if (!inMergedHead) unpushed = Number((await git($, repo.top, 'rev-list', '--count', branch, '--not', '--remotes')).out) || 0
1010    }
1011    const ahead = branch.startsWith('worktree-') && !isProtected ? Number((await git($, repo.top, 'rev-list', '--count', branch, '--not', mergedRef)).out) || 0 : undefined
1012    let worktree: BranchFacts['worktree']
1013    if (wt !== undefined) {
1014      const exists = !wt.prunable && (await $.fs.exists(wt.path))
1015      const status = exists ? await git($, wt.path, 'status', '--porcelain') : { ok: true, out: '' }
1016      worktree = {
1017        path: wt.path,
1018        exists,
1019        dirty: !status.ok || status.out !== '',
1020        locked: wt.locked,
1021        isMain: wt === main,
1022        isSession: normPath(wt.path, windows) === normPath(repo.top, windows),
1023        isActive: isActive(state, wt.path, now, windows),
1024      }
1025    }
1026    let remote: BranchFacts['remote']
1027    const remoteTip = remotes.get(`origin/${branch}`)
1028    if (remoteTip !== undefined) {
1029      let safe = false
1030      if (pr !== undefined || inMergedPr !== undefined) for (const h of heads) if (h === remoteTip || (await isAncestor(remoteTip, h))) safe = true
1031      if (!safe) safe = await isAncestor(remoteTip, mergedRef)
1032      // content already in the default branch: the remote branch is safe when it holds nothing the local one lacks
1033      if (!safe && contentMerged === true) safe = await isAncestor(remoteTip, tip)
1034      remote = { safe }
1035    }
1036    facts.push({ branch, isProtected, pr, gitMerged: gitMerged.has(branch), unpushed, ahead, inMergedPr, contentMerged, worktree, remote })
1037  }
1038  return { facts, worktrees, sync, defaultBranch }
1039}
1040
1041export async function tidy($: $, options: Record<string, unknown>, dryRun: boolean): Promise<TidyReport | undefined> {
1042  const repo = await repoAt($)
1043  if (repo === undefined) return undefined
1044  const windows = await isWindows($)
1045  const lines: string[] = []
1046  let acted = false
1047  if (!(await git($, repo.top, 'fetch', '--prune', 'origin')).ok) lines.push('git fetch failed; using the remote branches as last fetched')
1048  const { facts, worktrees, sync, defaultBranch } = await gatherFacts($, repo, windows)
1049  const plans = facts.map(decideBranch)
1050  const removed: string[] = []
1051
1052  if (sync.kind === 'none' && sync.reason) lines.push(sync.reason)
1053  if (sync.kind !== 'none') {
1054    const what = `fast-forward ${defaultBranch} to origin/${defaultBranch} (${sync.to.slice(0, 7)})`
1055    if (dryRun) lines.push(`would: ${what}`)
1056    else {
1057      const done =
1058        sync.kind === 'update-ref'
1059          ? await git($, repo.top, 'update-ref', `refs/heads/${defaultBranch}`, sync.to, sync.from)
1060          : await git($, sync.worktree, 'merge', '--ff-only', '--quiet', `origin/${defaultBranch}`)
1061      lines.push(done.ok ? `${what}: done` : `${what} failed: ${done.err.split('\n')[0]}`)
1062      if (done.ok) acted = true
1063    }
1064  }
1065
1066  if (!dryRun && plans.some(p => p.kind === 'delete' && p.prune)) await git($, repo.top, 'worktree', 'prune')
1067  const base = portBaseOf(options)
1068  for (const plan of plans) {
1069    if (plan.kind === 'skip') continue
1070    if (dryRun) {
1071      lines.push(`would: ${describePlan(plan)}`)
1072      if (plan.worktreePath) lines.push(...(await stopProcesses($, windows, [plan.worktreePath], [], true)).map(l => `  ${l}`))
1073      continue
1074    }
1075    acted = true
1076    if (plan.worktreePath) {
1077      lines.push(...(await cleanWorktree($, repo, plan.worktreePath, base, false)))
1078      const removed = await removeWorktree($, repo.top, plan.worktreePath, windows)
1079      if (!removed.ok) {
1080        lines.push(`${plan.branch}: worktree remove failed, left the branch: ${removed.err}`)
1081        continue
1082      }
1083      if (removed.note !== undefined) lines.push(`${plan.branch}: ${removed.note}`)
1084      const gone = plan.worktreePath
1085      await withState($, repo, async s => ({ state: markDone({ ...s, slots: releaseSlot(s.slots, gone, windows) }, gone, windows), value: undefined }))
1086    }
1087    let deleted = await git($, repo.top, 'branch', '-d', plan.branch)
1088    if (!deleted.ok && plan.allowForce) deleted = await git($, repo.top, 'branch', '-D', plan.branch)
1089    if (!deleted.ok) {
1090      lines.push(`${plan.branch}: branch delete failed: ${deleted.err.split('\n')[0]}`)
1091      continue
1092    }
1093    let line = `removed ${describePlan(plan)}`
1094    if (plan.deleteRemote) {
1095      const pushed = await git($, repo.top, 'push', 'origin', '--delete', plan.branch)
1096      if (!pushed.ok) line += ` (remote delete failed: ${pushed.err.split('\n')[0]})`
1097    }
1098    lines.push(line)
1099    removed.push(plan.branch)
1100  }
1101  if (acted) await git($, repo.top, 'fetch', '--prune', 'origin')
1102
1103  // orphans: processes naming a worktree folder of this repo that no longer exists, and slots whose folder is gone
1104  const prefix = normPath(`${repo.mainRoot}/.claude/worktrees`, windows) + '/'
1105  const live = new Set(worktrees.map(w => normPath(w.path, windows)))
1106  const snap = await snapshot($, windows, false)
1107  const dead = new Set<string>()
1108  for (const p of snap?.procs ?? []) {
1109    for (const path of worktreeMentions(p.cmd, prefix, windows)) {
1110      if (!live.has(path) && !dead.has(path) && !(await $.fs.exists(path))) dead.add(path)
1111    }
1112  }
1113  const table = (await readState($, repo)).slots
1114  const deadSlots: [number, Slot][] = []
1115  for (const [n, s] of Object.entries(table.slots)) if (!(await $.fs.exists(s.path))) deadSlots.push([Number(n), s])
1116  const orphanPorts = deadSlots.flatMap(([n]) => ports(base, n))
1117  const orphanPaths = [...dead, ...deadSlots.map(([, s]) => s.path)]
1118  if (orphanPaths.length > 0) {
1119    const stopped = await stopProcesses($, windows, orphanPaths, orphanPorts, dryRun)
1120    lines.push(...stopped.map(l => `orphan: ${l}`))
1121    if (!dryRun && stopped.some(l => l.startsWith('stopped'))) acted = true
1122    if (!dryRun && deadSlots.length > 0) {
1123      await withState($, repo, async t => ({
1124        state: deadSlots.reduce((acc, [, s]) => markDone({ ...acc, slots: releaseSlot(acc.slots, s.path, windows) }, s.path, windows), t),
1125        value: undefined,
1126      }))
1127      lines.push(`released slot${deadSlots.length === 1 ? '' : 's'} ${deadSlots.map(([n]) => n).join(', ')} (worktree gone)`)
1128    }
1129  }
1130  // agent branches never merged: reported, never deleted
1131  const unmerged = unmergedAgentWork(facts)
1132  lines.push(...unmerged)
1133  for (const plan of plans) if (plan.kind === 'skip') lines.push(describePlan(plan))
1134  return { plans, lines, acted, removed, unmerged }
1135}
1136
1137// ---------------------------------------------------------------------------------------------------------------
1138
1139// a hook of this plugin that fails leaves the call to the hooks beneath and the engine
1140// eslint-disable-next-line @typescript-eslint/no-explicit-any
1141const passThrough = ($: unknown, e: any, next: any) => next(e)
1142
1143export const register: Register = (on, options) => {
1144  const readOnly = readOnlyTypes(options.readOnlyAgents)
1145  const base = portBaseOf(options)
1146  let inRepo: boolean | undefined
1147  // the worktrees this session's WorktreeCreate made or reused; in memory on purpose: what a resumed or crashed
1148  // session left is for /tidy
1149  const own: Sweeps = { tracked: new Map(), chain: Promise.resolve() }
1150  const tracked = own.tracked
1151
1152  // 1. Every Agent call that can edit gets a worktree.
1153  on('classic.PreToolUse', { tool: 'Agent' }, async ($, e, next) => {
1154    const input = e as unknown as Record<string, unknown>
1155    if (input.isolation !== undefined) return next(e)
1156    inRepo ??= (await git($, undefined, 'rev-parse', '--is-inside-work-tree')).out === 'true'
1157    if (!needsWorktree(input, inRepo, readOnly)) return next(e)
1158    const result = await next(e)
1159    if (result.deny !== undefined || result.updatedInput !== undefined) return result
1160    return { ...result, updatedInput: withWorktree(input) }
1161  }).catch(passThrough)
1162
1163  // 2. The plugin creates every worktree itself, from the HEAD of the checkout the hook runs in: Claude Code's
1164  // default starts from origin/HEAD and misses the session's unmerged work. Then `.worktreeinclude` files are
1165  // copied, and the repo's setup script runs with a slot of ports.
1166  on('classic.WorktreeCreate', async ($, e, next) => {
1167    const windows = await isWindows($)
1168    const repo = await repoAt($, e.cwd)
1169    if (repo === undefined || !/^[A-Za-z0-9._-]+$/.test(e.name)) return next(e)
1170    const path = `${repo.mainRoot}/.claude/worktrees/${e.name}`
1171    const branch = `worktree-${e.name}`
1172    const head = await git($, e.cwd, 'rev-parse', '--verify', '--quiet', 'HEAD')
1173    const plan = planCreate({
1174      pathExists: await $.fs.exists(path),
1175      branchExists: (await git($, repo.top, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`)).ok,
1176      head: head.ok ? head.out : undefined,
1177    })
1178    if (plan.kind === 'default') return next(e)
1179    if (plan.kind !== 'reuse') {
1180      const added =
1181        plan.kind === 'add-branch'
1182          ? await git($, repo.top, 'worktree', 'add', path, branch)
1183          : await git($, repo.top, 'worktree', 'add', '-b', branch, path, plan.base)
1184      if (!added.ok) {
1185        await $.ui.toast(`worktrees: git worktree add failed; using the default worktree`)
1186        await $.ui.log(`worktrees: git worktree add ${path}: ${added.err}`, { to: 'debug' })
1187        return next(e)
1188      }
1189      const copied = await copyIncluded($, repo, path, windows)
1190      if (copied !== '') await $.ui.log(`worktrees: ${copied}`, { to: 'debug' })
1191    }
1192    const now = await $.clock.now()
1193    await withState($, repo, async state => ({ state: markActive(state, path, now, windows), value: undefined }))
1194    tracked.set(normPath(path, windows), path)
1195    const script = `${repo.top}/.claude/worktree-setup.${windows ? 'ps1' : 'sh'}`
1196    if (!(await $.fs.exists(script))) return { worktreePath: path }
1197
1198    const slot = await withState($, repo, async state => {
1199      const claimed = claimSlot(state.slots, path, e.name, now, windows)
1200      return { state: { ...state, slots: claimed.table }, value: claimed.slot }