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…

Keeps agents from stepping on each other's files, and cleans up after them.
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.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)..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..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.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.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:worktree-* branch, and not locked;git status --porcelain is empty;git merge-tree --write-tree HEAD <branch> gives HEAD's own tree (squash and cherry-pick);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.| Setting | Default | What it does |
|---|---|---|
readOnlyAgents | empty | Comma-separated agent types that never edit, added to the built-in list. |
portBase | 4000 | Slot N owns ports portBase + N*10 to portBase + N*10 + 9. |
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.
.claude/worktree-setup contractCommit 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:
| Variable | Value |
|---|---|
WORKTREE_PATH | The new worktree, absolute. |
WORKTREE_NAME | Its name, as Claude Code chose it (agent-… for an agent). |
WORKTREE_SLOT | Its slot, 1 or higher. Slot 0 is left for the main checkout. |
PORT_BASE | The first of its 10 ports. The worktree owns PORT_BASE to PORT_BASE+9. |
REPO_ROOT | The 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.
When one agent continues another's work, or needs your unmerged work:
CHECKPOINT:. The mod sees to this; see above.git merge --ff-only worktree-<name>. Then start the next leg, which branches from there.agent-a1 never matches agent-a12..claude/worktrees/, or a worktree folder that no longer exists.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:origin/<default>;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;main, master, or checked out in the main checkout or this session.origin/<default>.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.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.origin/<default>. Otherwise it is left and reported.origin/<default> and its checkout has no uncommitted changes, with git merge --ff-only or, when nothing has it checked out, git update-ref./tidy prints them; the cleanup after a merge writes them to the debug log.claude plugin validate plugins/worktrees
claude plugin test plugins/worktreeshooks/register.ts 1328 lines1import 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 }