Shows repo@branch and its listening ports in the hint line under the prompt, and a /worktrees pane to review and clear finished worktrees.

Shows the current worktree as repo@branch, and the ports it is listening on, in the dim hint line under the prompt, and adds a /worktrees pane that lists every worktree of the repository and clears the finished ones.
? for shortcuts · supermods@modal-logs-metrics · :3000 :5173
/plugin marketplace add Hula-Hoop-AI/supermods
/plugin install worktrees@supermods
After Claude Code's own hint, the line reads <worktree>@<branch>, then · :<port> … when something in the worktree is listening, and refreshes after each turn. A detached HEAD reads <worktree>@detached; outside a repository the hint line is left as Claude Code draws it.
/worktrees opens a pane with one row per worktree: its branch, whether its pull request was merged, how many commits it is ahead of the default branch, how many files are uncommitted, and which ports it is listening on. Remove N finished runs git worktree remove on the worktrees that are finished: not the main checkout or the current one, on a branch, with no uncommitted files, nothing listening, and either a merged pull request or no commits ahead. Branches are left alone.
None.
Events: session.start, turn.complete, command.run (/worktrees), ui.render (its pane, and PromptHint: adds its segment and keeps Claude Code's hint and other mods' segments).
Capabilities: $.process.run for git (worktree list, status, rev-list, rev-parse, symbolic-ref, branch, and worktree remove when you press the button), gh pr list (merged pull requests) and lsof (listening ports); $.ui.open, $.ui.resolve; $.state. No network access of its own (gh makes its own requests) and no file access.
/worktrees pane.lsof, so it works on macOS and Linux and shows nothing elsewhere.gh CLI, signed in; without it only "no commits ahead" marks a worktree finished.origin/HEAD, falling back to origin/main or origin/master.hooks/register.tsx 310 lines1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, Register } from 'claude-code';
3
4import type { Worktree, WorktreeScan } from '../types';
5
6const PANE = 'worktrees';
7const FALLBACK_BASE_REFS = ['origin/main', 'origin/master'];
8const MERGED_PR_LIMIT = '200';
9const SEPARATOR = ' · ';
10// Under this many columns a cut segment says nothing, so it is left out (the terminal's rule for `tail`).
11const MIN_SEGMENT_COLUMNS = 4;
12
13const scan = atom({ plugin: 'worktrees', key: 'scan' } as const, { baseRef: '', worktrees: [] });
14const note = atom({ plugin: 'worktrees', key: 'note' } as const, '');
15// What the prompt hint shows: `repo@branch` and the ports, or empty outside a repository.
16const hint = atom({ plugin: 'worktrees', key: 'hint' } as const, '');
17
18type WorktreeEntry = Pick<Worktree, 'path' | 'branch'>;
19
20async function stdoutOf($: EngineInterface, argv: string[], cwd?: string) {
21 try {
22 return await $.process.run(argv, cwd === undefined ? {} : { cwd });
23 } catch {
24 return { exitCode: 1, stdout: '' };
25 }
26}
27
28async function output($: EngineInterface, argv: string[], cwd?: string): Promise<string> {
29 const ran = await stdoutOf($, argv, cwd);
30 return ran.exitCode === 0 ? ran.stdout.trim() : '';
31}
32
33function baseName(path: string): string {
34 return path.split('/').pop() ?? path;
35}
36
37function lines(text: string): string[] {
38 return text.split('\n').filter(Boolean);
39}
40
41function plural(count: number, noun: string): string {
42 return `${count} ${noun}${count === 1 ? '' : 's'}`;
43}
44
45function parseWorktreeList(porcelain: string): WorktreeEntry[] {
46 return porcelain
47 .split('\n\n')
48 .map(lines)
49 .flatMap(fields => {
50 const path = fields.find(field => field.startsWith('worktree '))?.slice('worktree '.length);
51 const branch = fields.find(field => field.startsWith('branch '))?.slice('branch refs/heads/'.length);
52 return path === undefined ? [] : [{ path, branch: branch ?? null }];
53 });
54}
55
56async function defaultBaseRef($: EngineInterface): Promise<string> {
57 const remoteHead = await output($, ['git', 'symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
58 if (remoteHead !== '') return remoteHead;
59 for (const candidate of FALLBACK_BASE_REFS) {
60 if ((await output($, ['git', 'rev-parse', '--verify', '--quiet', candidate])) !== '') return candidate;
61 }
62 return '';
63}
64
65async function mergedBranches($: EngineInterface): Promise<Set<string>> {
66 const merged = await output($, [
67 'gh', 'pr', 'list', '--state', 'merged', '--limit', MERGED_PR_LIMIT,
68 '--json', 'headRefName', '--jq', '.[].headRefName',
69 ]);
70 return new Set(lines(merged));
71}
72
73function parseLsofFields(fields: string): Map<string, string[]> {
74 const namesByPid = new Map<string, string[]>();
75 let pid = '';
76 for (const field of lines(fields)) {
77 if (field.startsWith('p')) pid = field.slice(1);
78 if (field.startsWith('n')) namesByPid.set(pid, [...(namesByPid.get(pid) ?? []), field.slice(1)]);
79 }
80 return namesByPid;
81}
82
83async function listeningPortsByDirectory($: EngineInterface): Promise<Map<string, number[]>> {
84 const listening = await stdoutOf($, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpn']);
85 const addressesByPid = parseLsofFields(listening.stdout);
86 if (addressesByPid.size === 0) return new Map();
87
88 const directories = await stdoutOf($, ['lsof', '-a', '-d', 'cwd', '-Fpn', '-p', [...addressesByPid.keys()].join(',')]);
89 const portsByDirectory = new Map<string, number[]>();
90 for (const [pid, [directory]] of parseLsofFields(directories.stdout)) {
91 if (directory === undefined) continue;
92 const ports = (addressesByPid.get(pid) ?? []).map(address => Number(address.split(':').pop()));
93 portsByDirectory.set(directory, [...(portsByDirectory.get(directory) ?? []), ...ports]);
94 }
95 return portsByDirectory;
96}
97
98function owningWorktree(directory: string, paths: string[]): string | undefined {
99 return paths
100 .filter(path => directory === path || directory.startsWith(`${path}/`))
101 .sort((a, b) => b.length - a.length)[0];
102}
103
104function portsByWorktree(portsByDirectory: Map<string, number[]>, paths: string[]): Map<string, number[]> {
105 const grouped = new Map<string, Set<number>>();
106 for (const [directory, ports] of portsByDirectory) {
107 const owner = owningWorktree(directory, paths);
108 if (owner === undefined) continue;
109 grouped.set(owner, new Set([...(grouped.get(owner) ?? []), ...ports]));
110 }
111 return new Map([...grouped].map(([path, ports]) => [path, [...ports].sort((a, b) => a - b)]));
112}
113
114async function scanWorktrees($: EngineInterface): Promise<WorktreeScan> {
115 const [listing, currentRoot, baseRef, merged, portsByDirectory] = await Promise.all([
116 output($, ['git', 'worktree', 'list', '--porcelain']),
117 output($, ['git', 'rev-parse', '--show-toplevel']),
118 defaultBaseRef($),
119 mergedBranches($),
120 listeningPortsByDirectory($),
121 ]);
122 const entries = parseWorktreeList(listing);
123 const ports = portsByWorktree(portsByDirectory, entries.map(entry => entry.path));
124 const worktrees = await Promise.all(
125 entries.map(async ({ path, branch }, index) => {
126 const [status, ahead] = await Promise.all([
127 output($, ['git', 'status', '--porcelain'], path),
128 baseRef === '' ? '' : output($, ['git', 'rev-list', '--count', `${baseRef}..HEAD`], path),
129 ]);
130 return {
131 path,
132 name: baseName(path),
133 branch,
134 isMain: index === 0,
135 isCurrent: path === currentRoot,
136 uncommittedFiles: lines(status).length,
137 commitsAhead: ahead === '' ? null : Number(ahead),
138 isMerged: branch !== null && merged.has(branch),
139 ports: ports.get(path) ?? [],
140 };
141 })
142 );
143 return { baseRef, worktrees };
144}
145
146function isRemovable(worktree: Worktree): boolean {
147 return (
148 !worktree.isMain &&
149 !worktree.isCurrent &&
150 worktree.branch !== null &&
151 worktree.uncommittedFiles === 0 &&
152 worktree.ports.length === 0 &&
153 (worktree.isMerged || worktree.commitsAhead === 0)
154 );
155}
156
157function portList(ports: number[]): string {
158 return ports.map(port => `:${port}`).join(' ');
159}
160
161function describe(worktree: Worktree): string {
162 const facts = [
163 worktree.isMain && 'main checkout',
164 worktree.isMerged && 'PR merged',
165 worktree.commitsAhead !== null && worktree.commitsAhead > 0 && `${plural(worktree.commitsAhead, 'commit')} ahead`,
166 worktree.uncommittedFiles > 0 && `${plural(worktree.uncommittedFiles, 'uncommitted file')}`,
167 worktree.ports.length > 0 && `listening on ${portList(worktree.ports)}`,
168 ].filter(Boolean);
169 const marker = worktree.isCurrent ? '>' : ' ';
170 return `${marker} ${worktree.name} (${worktree.branch ?? 'detached'}) ${facts.join(', ')}`;
171}
172
173async function removeFinished($: EngineInterface): Promise<string> {
174 const finished = (await scanWorktrees($)).worktrees.filter(isRemovable);
175 const kept: string[] = [];
176 for (const { path, name } of finished) {
177 const removed = await stdoutOf($, ['git', 'worktree', 'remove', path]);
178 if (removed.exitCode !== 0) kept.push(name);
179 }
180 const keptNote = kept.length > 0 ? `; git kept ${kept.join(', ')}` : '';
181 return `Removed ${plural(finished.length - kept.length, 'worktree')}, branches untouched${keptNote}.`;
182}
183
184export function hintText(root: string, branch: string, ports: number[]): string {
185 return [`${baseName(root)}@${branch || 'detached'}`, portList(ports)].filter(Boolean).join(SEPARATOR);
186}
187
188/** The segment cut to `room` columns, or empty when too little of it would show. */
189export function fit(segment: string, room: number): string {
190 if (room < MIN_SEGMENT_COLUMNS) return '';
191 return segment.length <= room ? segment : `${segment.slice(0, room - 1)}…`;
192}
193
194async function refreshHint($: EngineInterface): Promise<void> {
195 const root = await output($, ['git', 'rev-parse', '--show-toplevel']);
196 if (root === '') {
197 await update($, hint, () => '');
198 return;
199 }
200 const [branch, listing, portsByDirectory] = await Promise.all([
201 output($, ['git', 'branch', '--show-current']),
202 output($, ['git', 'worktree', 'list', '--porcelain']),
203 listeningPortsByDirectory($),
204 ]);
205 const paths = parseWorktreeList(listing).map(entry => entry.path);
206 const ports = portsByWorktree(portsByDirectory, paths).get(root) ?? [];
207 const text = hintText(root, branch, ports);
208 await update($, hint, () => text);
209}
210
211function notPlaced(what: string, reason: string) {
212 return `${what} is open, but this surface is not showing it: ${reason}`;
213}
214
215export const register: Register = on => {
216 on('session.start', async ($, e, next) => {
217 await $.command.register({
218 name: 'worktrees',
219 description: 'Review git worktrees and clear the finished ones',
220 });
221 void refreshHint($);
222
223 return next(e);
224 });
225
226 on('turn.complete', ($, e, next) => {
227 void refreshHint($);
228
229 return next(e);
230 });
231
232 // The terminal adds `tail` to its own live line and cuts it at the row's end; the desktop
233 // does not draw `tail` yet, so there the engine's line is wrapped with the segment beside it.
234 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
235 const segment = await read($, hint);
236 if (segment === '') return next(e);
237 if (e.surface === 'terminal') {
238 const tail = e.props.tail ? `${e.props.tail}${SEPARATOR}${segment}` : segment;
239 return next({ ...e, props: { ...e.props, tail } });
240 }
241 const drawn = await next(e);
242 const room = e.viewport === undefined ? segment.length : e.viewport.columns - e.props.hint.length - SEPARATOR.length;
243 const shown = fit(segment, room);
244 if (shown === '') return drawn;
245 const { Box, Text } = $.ui.resolve(e);
246
247 return (
248 <Box flexDirection="row">
249 {drawn}
250 <Text dimColor wrap="truncate-end">{`${SEPARATOR}${shown}`}</Text>
251 </Box>
252 );
253 });
254
255 on('command.run', { command: 'worktrees' }, async $ => {
256 const opened = await $.ui.open({ id: PANE, title: 'Worktrees' });
257 const scanned = await scanWorktrees($);
258 await update($, scan, () => scanned);
259
260 return { text: opened.isPlaced ? 'Worktrees pane opened.' : notPlaced('The Worktrees pane', opened.reason) };
261 });
262
263 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
264 const { Box, Button, Text } = $.ui.resolve(e);
265 const { baseRef, worktrees } = await read($, scan);
266 const message = await read($, note);
267 const finishedCount = worktrees.filter(isRemovable).length;
268
269 return (
270 <Box flexDirection="column">
271 {worktrees.length === 0 && <Text dimColor>No worktrees found.</Text>}
272 {baseRef !== '' && <Text dimColor>Commits ahead are counted against {baseRef}.</Text>}
273 {worktrees.map(worktree => (
274 <Text bold={worktree.isCurrent} dimColor={isRemovable(worktree)} wrap="truncate-end">
275 {describe(worktree)}
276 </Text>
277 ))}
278 <Box>
279 <Button
280 key="refresh"
281 label="Refresh"
282 hotkey="r"
283 onPress={async () => {
284 const scanned = await scanWorktrees($);
285 await update($, scan, () => scanned);
286 }}
287 />
288 {finishedCount > 0 && (
289 <Button
290 key="remove-finished"
291 label={`Remove ${finishedCount} finished`}
292 hotkey="x"
293 onPress={async () => {
294 const outcome = await removeFinished($);
295 const scanned = await scanWorktrees($);
296 await update($, scan, () => scanned);
297 await update($, note, () => outcome);
298 }}
299 />
300 )}
301 </Box>
302 {finishedCount > 0 && (
303 <Text dimColor>Finished: no uncommitted files, nothing listening, and PR merged or no commits ahead.</Text>
304 )}
305 {message !== '' && <Text dimColor>{message}</Text>}
306 </Box>
307 );
308 });
309};
310types/index.d.ts 20 lines1export type Worktree = {
2 path: string;
3 name: string;
4 branch: string | null;
5 isMain: boolean;
6 isCurrent: boolean;
7 uncommittedFiles: number;
8 commitsAhead: number | null;
9 isMerged: boolean;
10 ports: number[];
11};
12
13export type WorktreeScan = { baseRef: string; worktrees: Worktree[] };
14
15declare module 'claude-code' {
16 interface PluginState {
17 'worktrees': { scan: WorktreeScan; note: string; hint: string };
18 }
19}
20