Shows the chat's wop environment in the prompt footer, lists wop environments and git worktrees to switch between or tear down, and asks what to do with them…

Claude Code mod for wop per-branch environments: see, switch and clean up worktrees from your chats.
<img src="demo/hero.gif" alt="A Claude Code chat opens the worktree list, moves into the feature/login environment, checks the app on that environment's port with curl, and tears the environment down on /exit">
wop gives each git branch its own worktree, ports and database. This mod keeps the chat's environment in view, moves the chat between environments without leaving Claude Code, tells Claude which ports and database to use, and asks what to do with each environment when you exit, so none are left running by accident.
Requirements:
wop 3.x on your PATHjq and bash, for the optional shell wrapper and claude -w scriptsAt the Claude Code prompt:
/plugin install wop-worktrees --marketplace FLampa/wop-worktrees
Answer y to add the marketplace and pick the user scope. A local-scope install only loads in the checkout that holds it, not in its worktrees. The mod draws nothing outside repos with wop environments.
To make it available to everyone working on a project, commit this to the project's .claude/settings.json:
{
"extraKnownMarketplaces": {
"wop-worktrees": { "source": { "source": "github", "repo": "FLampa/wop-worktrees" } }
}
}
Claude Code then registers the marketplace for each person who opens the project, and the mod appears in /plugin under Discover. Each person installs it with /plugin install wop-worktrees@wop-worktrees. Adding "wop-worktrees@wop-worktrees": true under enabledPlugins instead loads it for everyone, without asking.
If your organization's Claude admin adds wop-worktrees to pluginSuggestionMarketplaces in managed settings, Claude Code also suggests the mod once Claude runs wop or reads a .devmanager.yml.
The right end of the prompt footer shows the environment the chat is in and a button: ⎇ ● login :5100 · View all worktrees (2) ⌃Q. The worktree list has one card per wop environment (services, ports, database) and one line per plain git worktree. Picking one moves the chat there, restarting stopped services first.
<img src="demo/switch.gif" width="760" alt="The worktree list shows feature/search stopped; picking it restarts its services, the footer switches to search :5101, and Ctrl+Q reopens the list with both environments running">
When Claude runs wop up or wop restart for a branch, the mod asks Move or Stay at the end of the turn, once the environment has registered. It asks once per environment, never mid-turn, and not when the chat is already there.
A chat in a wop environment gets its ports, web URL and database in its context, plus "leave other environments alone", so it doesn't guess port 3000. Moving mid-chat adds a short note instead of rewriting that context, which keeps Claude's prompt cache.
For each environment the chat used, /exit asks Keep running, Stop services (wop stop) or Tear down (wop down). It warns about uncommitted changes, commits that are on no remote, and other chats working in the same worktree.
<img src="demo/exit.gif" width="760" alt="/exit in feature/login warns about one uncommitted change and one commit not on the remote, and the chat picks Tear down">
Claude Code resumes a chat in the folder you run claude --resume from. If the chat was in another worktree of the same repo when it closed, the mod moves it back there.
A chat uses an environment when its working directory is inside it, a tool call names it, or Claude ran wop up/wop restart for its branch, even if the environment registers minutes later.
Open the worktree list with /worktrees, a click on the footer button, or Ctrl+Q.
| Key | In the worktree list |
|---|---|
↑ ↓ | Move between environments and worktrees |
⏎ | Move the chat there |
x | Tear down the wop environment (wop down) or remove the git worktree, after asking |
u | Stop tracking an environment the chat only looked at, so /exit skips it |
Esc | Close the list |
/exit asks about every environment the chat used. Esc at that prompt cancels the exit.
Mods can't read keys directly, but a mod button can name one of Claude Code's keybinding actions and be pressed by your binding for it. The list buttons use app:cycleDiffBase, which Claude Code only handles while /diff's panel is open. Add this to ~/.claude/keybindings.json (an object with a bindings array; a bare array is rejected):
{
"bindings": [
{ "context": "Global", "bindings": { "ctrl+q": "app:cycleDiffBase" } }
]
}
While /diff's panel is open, Ctrl+Q cycles its diff base instead.
A mod can't catch Ctrl+C: Claude Code reserves the key, and nothing can be asked once the interface is closing. So the mod hands the chat's environments to a claude shell function instead, which asks keep running, stop services or tear down after Claude Code exits, with the same warnings. It asks nothing after /exit (the mod already did), after claude -p, or when the terminal isn't interactive.
Add this to ~/.zshrc or ~/.bashrc, with the path to a clone of this repo:
source /path/to/wop-worktrees/scripts/claude-wrapper.sh
Closing the terminal tab skips the question, since the shell goes with it.
<img src="demo/ctrl-c.gif" width="760" alt="A double Ctrl+C closes Claude Code, then the terminal asks whether to keep, stop or tear down feature/login, and t tears it down">
claude -w <type>/<name> through wopscripts/worktree-create.sh and scripts/worktree-remove.sh are WorktreeCreate and WorktreeRemove hooks. With them, claude -w feature/login runs wop up and starts the chat in the new environment. A branch that only exists on the remote is checked out from it, and a new one starts from the remote's default branch. On exit, Claude Code removes a worktree with no changes on its own, which runs wop down. If it has changes, Claude Code asks Keep worktree or Remove worktree: Remove runs wop down, and on Keep the mod runs wop stop. Names without a / (and subagent worktrees) get a plain git worktree under .claude/worktrees/.
<img src="demo/worktree-flag.gif" width="760" alt="claude -w feature/checkout creates a wop environment and opens the chat inside it, with checkout :5102 in the footer">
The hooks must live in a settings file, because hooks a plugin declares load too late for --worktree. Clone this repo somewhere stable and add to your user or project settings:
{
"hooks": {
"WorktreeCreate": [
{ "hooks": [{ "type": "command", "command": "\"/path/to/wop-worktrees/scripts/worktree-create.sh\"", "timeout": 1200 }] }
],
"WorktreeRemove": [
{ "hooks": [{ "type": "command", "command": "\"/path/to/wop-worktrees/scripts/worktree-remove.sh\"", "timeout": 300 }] }
]
}
}
/exit asks there. The shell wrapper asks afterwards, and claude -w chats get Claude Code's own keep/remove prompt./worktrees or Ctrl+Q."tui": "fullscreen").~/.claude/wop-worktrees/chats/.wop down. It deletes the worktree folder, uncommitted work included, and drops its databases. The git branch stays.claude plugin validate .
claude plugin test .
claude --plugin-dir . loads your working copy for one session. Run tsc -p . for type checks: Claude Code writes the API types to .claude-plugin/types/ the first time it loads the mod from a folder it watches.
The GIFs are VHS tapes in demo/. demo/record.sh records all of them, or only the ones you name (demo/record.sh hero exit). For each tape it builds a throwaway repo with two wop environments in /Users/Shared/acme-shop, records, and removes the repo again. Claude Code runs through demo/bin/claude, which skips your own settings and loads this working copy. It needs vhs, wop, Postgres and a signed-in Claude Code. Claude Code must trust that folder first: run demo/setup.sh, start claude in /Users/Shared/acme-shop once to accept the trust prompt, then run demo/setup.sh down.
Issues and pull requests are welcome. CI runs the two commands above and shellcheck.
hooks/register.tsx 867 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { WopEnvironment, WopPicker, WopPlainWorktree, WopRisk } from '../types'
5import {
6 WOP_COMMAND,
7 branchesIn,
8 environmentFacts,
9 exitQuestion,
10 fit,
11 health,
12 insideWorktree,
13 leftEnvironmentNote,
14 matchesToolCall,
15 parseGitWorktrees,
16 plural,
17 sameJson,
18 serviceColor,
19 warnings,
20 webService,
21 worktreesIn,
22} from './text'
23import type { RegistryEntry } from './text'
24
25type Engine = EngineInterface
26
27const environmentsState = atom({ plugin: 'wop-worktrees', key: 'environments' } as const, [])
28const attachedState = atom({ plugin: 'wop-worktrees', key: 'attached' } as const, [])
29const pendingState = atom({ plugin: 'wop-worktrees', key: 'pending' } as const, [])
30const pickerState = atom({ plugin: 'wop-worktrees', key: 'picker' } as const, null)
31const focusedState = atom({ plugin: 'wop-worktrees', key: 'focused' } as const, null)
32const confirmingState = atom({ plugin: 'wop-worktrees', key: 'confirming' } as const, null)
33const riskState = atom({ plugin: 'wop-worktrees', key: 'risk' } as const, null)
34const announcedState = atom({ plugin: 'wop-worktrees', key: 'announced' } as const, null)
35const offersState = atom({ plugin: 'wop-worktrees', key: 'offers' } as const, [])
36const offeredState = atom({ plugin: 'wop-worktrees', key: 'offered' } as const, [])
37
38type Chat = { cwd: string; attached: string[] }
39
40const PENDING_TTL_MS = 30 * 60_000
41const CHAT_FRESH_MS = 60_000
42const CHAT_BEAT_MS = 20_000
43const STALE_CHAT_MS = 24 * 60 * 60_000
44const STALE_SESSION_MS = 30 * 24 * 60 * 60_000
45const RETURN_DELAY_MS = 300
46const OFFER_DELAY_MS = 300
47const MOVE = 'Move'
48const STAY = 'Stay'
49
50const databases = new Map<string, string>()
51const mainCheckouts = new Map<string, string | null>()
52const resolvedPaths = new Map<string, string>()
53let lastBeat = { text: '', at: 0 }
54let askedOnExit = false
55let resuming = false
56let started = false
57
58async function home($: Engine) {
59 return (await $.env.get('HOME')) ?? ''
60}
61
62async function stateDir($: Engine) {
63 return (await home($)) + '/.claude/wop-worktrees'
64}
65
66async function registryPath($: Engine) {
67 return ((await $.env.get('WOP_STATE_DIR')) || (await home($)) + '/.config/devmanager') + '/registry.json'
68}
69
70async function markerPath($: Engine) {
71 return (await stateDir($)) + '/claude-sessions/' + (await $.session.id())
72}
73
74async function chatsDir($: Engine) {
75 return (await stateDir($)) + '/chats'
76}
77
78async function storeKey($: Engine) {
79 return 'attached:' + (await $.session.id())
80}
81
82async function resolvePath($: Engine, path: string) {
83 const known = resolvedPaths.get(path)
84 if (known) return known
85 const shell = await $.process.run(['sh', '-c', 'cd "$1" && pwd -P', 'sh', path])
86 if (shell.exitCode !== 0) return path
87 const resolved = shell.stdout.trim()
88 resolvedPaths.set(path, resolved)
89 return resolved
90}
91
92async function readEntries($: Engine): Promise<RegistryEntry[] | null> {
93 let entries: RegistryEntry[]
94 try {
95 entries = JSON.parse(await $.fs.read(await registryPath($))).entries ?? []
96 } catch {
97 return (await $.fs.exists(await registryPath($))) ? null : []
98 }
99 return Promise.all(
100 entries.map(async (entry) => ({
101 ...entry,
102 project_path: await resolvePath($, entry.project_path),
103 worktree_path: await resolvePath($, entry.worktree_path),
104 })),
105 )
106}
107
108async function sessionWorktree($: Engine) {
109 const path = await markerPath($)
110 if (!(await $.fs.exists(path))) return null
111 return (await $.fs.read(path)).trim()
112}
113
114async function livePids($: Engine, pids: number[]) {
115 if (pids.length === 0) return new Set<number>()
116 const ps = await $.process.run(['ps', '-o', 'pid=', '-p', pids.join(',')])
117 return new Set(ps.stdout.split('\n').map((line) => Number(line.trim())).filter(Boolean))
118}
119
120async function databaseName($: Engine, worktree: string) {
121 const known = databases.get(worktree)
122 if (known) return known
123 let name: string | null = null
124 try {
125 name = (await $.fs.read(worktree + '/.env')).match(/^DATABASE_URL=\S*\/([^/\s?]+)/m)?.[1] ?? null
126 } catch {}
127 if (name) databases.set(worktree, name)
128 return name
129}
130
131function forgetDatabase(worktree: string) {
132 databases.delete(worktree)
133}
134
135async function mainCheckout($: Engine, cwd: string) {
136 const known = mainCheckouts.get(cwd)
137 if (known !== undefined) return known
138 const git = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd })
139 const commonDir = git.exitCode === 0 ? git.stdout.trim() : null
140 const main = commonDir ? commonDir.slice(0, commonDir.lastIndexOf('/')) : null
141 mainCheckouts.set(cwd, main)
142 return main
143}
144
145async function saveAttached($: Engine, worktrees: string[]) {
146 await update($, attachedState, () => worktrees)
147 await $.store.set(await storeKey($), { worktrees, at: await $.clock.now() })
148}
149
150async function restoreAttached($: Engine) {
151 const saved = (await $.store.get(await storeKey($))) as { worktrees?: string[] } | undefined
152 if (saved?.worktrees) await update($, attachedState, () => saved.worktrees ?? [])
153}
154
155function broughtUp(entries: RegistryEntry[], texts: string[]) {
156 return worktreesIn(entries.filter((entry) => texts.some((text) => branchesIn(text).has(entry.branch))))
157}
158
159async function queueOffers($: Engine, worktrees: string[]) {
160 const offered = await read($, offeredState)
161 const fresh = worktrees.filter((worktree) => !offered.includes(worktree))
162 if (fresh.length === 0) return
163 await update($, offeredState, () => [...offered, ...fresh])
164 await update($, offersState, (offers) => [...offers, ...fresh])
165}
166
167async function attachFromToolCall($: Engine, toolCall: unknown) {
168 const text = JSON.stringify(toolCall)
169 const entries = (await readEntries($)) ?? []
170 if (WOP_COMMAND.test(text)) {
171 const until = (await $.clock.now()) + PENDING_TTL_MS
172 await update($, pendingState, (pending) => [...pending, { text, until }])
173 await queueOffers($, broughtUp(entries, [text]))
174 }
175 const touched = worktreesIn(entries.filter((entry) => matchesToolCall(text, entry)))
176 const attached = await read($, attachedState)
177 if (touched.some((worktree) => !attached.includes(worktree))) await saveAttached($, [...new Set([...attached, ...touched])])
178}
179
180async function attach($: Engine, cwd: string, entries: RegistryEntry[]) {
181 const registered = worktreesIn(entries)
182 const now = await $.clock.now()
183 const pending = (await read($, pendingState)).filter((mention) => mention.until > now)
184 const matched = (text: string) => entries.some((entry) => matchesToolCall(text, entry))
185 const fromPending = worktreesIn(entries.filter((entry) => pending.some((mention) => matchesToolCall(mention.text, entry))))
186 const here = registered.filter((worktree) => insideWorktree(cwd, worktree))
187 const before = await read($, attachedState)
188 const attached = [...new Set([...before, ...fromPending, ...here])].filter((worktree) => registered.includes(worktree))
189 const waiting = pending.filter((mention) => !matched(mention.text))
190 await queueOffers($, broughtUp(entries, pending.map((mention) => mention.text)))
191
192 if (!sameJson(waiting, await read($, pendingState))) await update($, pendingState, () => waiting)
193 if (!sameJson(attached, before)) await saveAttached($, attached)
194 return attached
195}
196
197async function beat($: Engine, cwd: string, attached: string[]) {
198 const text = JSON.stringify({ cwd, attached })
199 const now = await $.clock.now()
200 if (text === lastBeat.text && now - lastBeat.at < CHAT_BEAT_MS) return
201 await $.fs.write((await chatsDir($)) + '/' + (await $.session.id()) + '.json', text)
202 lastBeat = { text, at: now }
203}
204
205async function otherChats($: Engine) {
206 const dir = await chatsDir($)
207 const own = (await $.session.id()) + '.json'
208 const now = await $.clock.now()
209 let files: Awaited<ReturnType<Engine['fs']['list']>> = []
210 try {
211 files = await $.fs.list(dir)
212 } catch {
213 return []
214 }
215 const fresh = files.filter((file) => file.kind === 'file' && file.name !== own && now - file.mtimeMs < CHAT_FRESH_MS)
216 const chats = await Promise.all(
217 fresh.map(async (file) => {
218 try {
219 return JSON.parse(await $.fs.read(dir + '/' + file.name)) as Chat
220 } catch {
221 return null
222 }
223 }),
224 )
225 return chats.filter((chat) => chat !== null)
226}
227
228async function endChat($: Engine) {
229 await $.process.run(['rm', '-f', (await chatsDir($)) + '/' + (await $.session.id()) + '.json'])
230}
231
232async function describeAll($: Engine, entries: RegistryEntry[], cwd: string, attached: string[], main: string | null) {
233 const alive = await livePids($, entries.map((entry) => entry.pid ?? 0).filter(Boolean))
234 const claudeWorktree = await sessionWorktree($)
235 const chats = await otherChats($)
236 return Promise.all(
237 worktreesIn(entries).map(async (worktree): Promise<WopEnvironment> => {
238 const own = entries.filter((entry) => entry.worktree_path === worktree)
239 const first = own[0] as RegistryEntry
240 return {
241 worktree,
242 branch: first.branch,
243 project: first.project_path,
244 database: await databaseName($, worktree),
245 missing: !(await $.fs.exists(worktree)),
246 claudeWorktree: claudeWorktree === worktree,
247 current: insideWorktree(cwd, worktree),
248 attached: attached.includes(worktree),
249 inProject: first.project_path === main,
250 otherChats: chats.filter((chat) => insideWorktree(chat.cwd, worktree) || chat.attached.includes(worktree)).length,
251 services: own.map((entry) => ({ name: entry.service, port: entry.port, cmd: entry.cmd ?? '', running: alive.has(entry.pid ?? 0) })),
252 }
253 }),
254 )
255}
256
257async function refresh($: Engine) {
258 const entries = await readEntries($)
259 if (entries === null) return
260 const cwd = await $.session.cwd()
261 const attached = await attach($, cwd, entries)
262 await beat($, cwd, attached)
263 const main = await mainCheckout($, cwd)
264 const described = await describeAll($, entries, cwd, attached, main)
265 const environments = described.filter((environment) => environment.inProject || environment.current || environment.attached)
266 if (!sameJson(environments, await read($, environmentsState))) await update($, environmentsState, () => environments)
267 await announce($, cwd, environments.find((environment) => environment.current) ?? null)
268}
269
270async function announce($: Engine, cwd: string, current: WopEnvironment | null) {
271 const facts = current ? environmentFacts(current) : null
272 const before = await read($, announcedState)
273 if (facts === before) return
274 await update($, announcedState, () => facts)
275 if (!started) return
276 const text = facts ?? (before ? leftEnvironmentNote(cwd) : null)
277 if (text) await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
278}
279
280async function prune($: Engine) {
281 const now = await $.clock.now()
282 for (const key of await $.store.keys()) {
283 const saved = (await $.store.get(key)) as { at?: number } | undefined
284 if ((key.startsWith('attached:') || key.startsWith('cwd:')) && now - (saved?.at ?? 0) > STALE_SESSION_MS) await $.store.delete(key)
285 }
286
287 const dir = await chatsDir($)
288 const stale = (await $.fs.list(dir).catch(() => [])).filter((file) => now - file.mtimeMs > STALE_CHAT_MS)
289 if (stale.length > 0) await $.process.run(['rm', '-f', ...stale.map((file) => dir + '/' + file.name)])
290
291 const entries = await readEntries($)
292 if (entries === null) return
293 const registered = worktreesIn(entries)
294 const markers = (await stateDir($)) + '/claude-sessions'
295 const orphans: string[] = []
296 for (const file of await $.fs.list(markers).catch(() => [])) {
297 const path = (await $.fs.read(markers + '/' + file.name).catch(() => '')).trim()
298 if (!registered.includes(path)) orphans.push(markers + '/' + file.name)
299 }
300 if (orphans.length > 0) await $.process.run(['rm', '-f', ...orphans])
301}
302
303async function runWop($: Engine, environment: WopEnvironment, action: 'restart' | 'stop' | 'down') {
304 await $.process.run(['wop', action, environment.branch], { cwd: environment.project, timeoutMs: 300_000 })
305 if (action === 'down' && environment.claudeWorktree) await $.process.run(['rm', '-f', await markerPath($)])
306 if (action === 'down') forgetDatabase(environment.worktree)
307}
308
309async function worktreeRisk($: Engine, path: string, ignored: readonly string[]): Promise<WopRisk> {
310 try {
311 if (!(await $.fs.exists(path))) return { changes: 0, unpushed: 0, failed: false }
312 const status = await $.process.run(['git', 'status', '--porcelain'], { cwd: path })
313 const changes = status.stdout.split('\n').filter((line) => line.trim() !== '' && !ignored.includes(line.slice(3))).length
314 const commits = await $.process.run(['git', 'rev-list', '--count', 'HEAD', '--not', '--remotes'], { cwd: path })
315 return { changes, unpushed: Number(commits.stdout.trim()) || 0, failed: status.exitCode !== 0 }
316 } catch {
317 return { changes: 0, unpushed: 0, failed: true }
318 }
319}
320
321function serviceLogs(environment: WopEnvironment) {
322 return environment.services.map((service) => service.name + '.log')
323}
324
325type Ui = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
326
327const PICKER = 'wop-worktrees'
328const CYCLE_ACTION = 'app:cycleDiffBase'
329
330type View = {
331 picker: WopPicker | null
332 wop: WopEnvironment[]
333 focused: string | null
334 confirming: string | null
335 risk: WopRisk | null
336}
337
338type Removal =
339 | { kind: 'wop'; key: string; environment: WopEnvironment }
340 | { kind: 'plain'; key: string; worktree: WopPlainWorktree; main: string }
341
342type PlainRow = { key: string; icon: string; label: string; current: boolean; onPress: () => void }
343
344async function readView($: Engine): Promise<View> {
345 return {
346 picker: await read($, pickerState),
347 wop: (await read($, environmentsState))
348 .filter((environment) => environment.inProject)
349 .sort((left, right) => left.branch.localeCompare(right.branch)),
350 focused: await read($, focusedState),
351 confirming: await read($, confirmingState),
352 risk: await read($, riskState),
353 }
354}
355
356async function loadPicker($: Engine): Promise<WopPicker> {
357 const cwd = await $.session.cwd()
358 const main = await mainCheckout($, cwd)
359 if (!main) return { main: null, plain: [] }
360
361 const listing = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: main })
362 const worktrees = parseGitWorktrees(listing.stdout).filter((worktree) => worktree.path !== main)
363 const wopPaths = new Set((await read($, environmentsState)).map((environment) => environment.worktree))
364
365 return {
366 main: { path: main, current: !worktrees.some((worktree) => insideWorktree(cwd, worktree.path)) },
367 plain: worktrees
368 .filter((worktree) => !wopPaths.has(worktree.path))
369 .map((worktree) => ({ ...worktree, current: insideWorktree(cwd, worktree.path) })),
370 }
371}
372
373async function reloadPicker($: Engine) {
374 await refresh($)
375 const picker = await loadPicker($)
376 await update($, pickerState, () => picker)
377 await update($, focusedState, () => null)
378}
379
380function currentRowKey(view: View) {
381 return view.wop.find((environment) => environment.current)?.worktree ?? view.picker?.plain.find((worktree) => worktree.current)?.path ?? 'main'
382}
383
384async function openPicker($: Engine) {
385 await update($, pickerState, () => null)
386 await update($, confirmingState, () => null)
387 await update($, focusedState, () => null)
388 await $.ui.open({ id: PICKER, title: 'wop worktrees', focus: true, closeOnEscape: true })
389 await reloadPicker($)
390 try {
391 await $.ui.focus({ requestId: PICKER, key: currentRowKey(await readView($)) })
392 } catch {}
393}
394
395async function closePicker($: Engine) {
396 await $.ui.close({ id: PICKER })
397}
398
399async function togglePicker($: Engine) {
400 const isOpen = (await $.ui.panes()).some((pane) => pane.id === PICKER)
401 return isOpen ? closePicker($) : openPicker($)
402}
403
404async function moveTo($: Engine, path: string, environment: WopEnvironment | null) {
405 await closePicker($)
406 await enter($, path, environment)
407}
408
409async function enter($: Engine, path: string, environment: WopEnvironment | null) {
410 if (environment?.services.some((service) => !service.running)) {
411 $.ui.toast('Restarting ' + environment.branch + '…')
412 await runWop($, environment, 'restart')
413 }
414 const attached = await read($, attachedState)
415 if (environment && !attached.includes(environment.worktree)) await saveAttached($, [...attached, environment.worktree])
416 await $.command.run({ command: 'cd', args: path })
417 await refresh($)
418}
419
420async function offerMoves($: Engine) {
421 const offers = await read($, offersState)
422 if (offers.length === 0) return
423 await update($, offersState, () => [])
424 await refresh($)
425 for (const worktree of offers) {
426 const environment = (await read($, environmentsState)).find((candidate) => candidate.worktree === worktree)
427 if (!environment || environment.current || environment.missing) continue
428 let answer: string
429 try {
430 answer = await $.ui.ask('Move this chat to ' + environment.branch + '?', { header: 'wop', options: [MOVE, STAY] })
431 } catch {
432 continue
433 }
434 if (answer === MOVE) return enter($, environment.worktree, environment)
435 }
436}
437
438function removalFor(view: View, key: string | null): Removal | null {
439 if (!key) return null
440 const environment = view.wop.find((candidate) => candidate.worktree === key)
441 if (environment) return { kind: 'wop', key, environment }
442 const worktree = view.picker?.plain.find((candidate) => candidate.path === key)
443 const main = view.picker?.main?.path
444 return worktree && main ? { kind: 'plain', key, worktree, main } : null
445}
446
447function removalText(removal: Removal) {
448 if (removal.kind === 'wop') {
449 const { branch, database } = removal.environment
450 return {
451 title: 'Tear down ' + branch + '?',
452 detail: 'Deletes the worktree folder (uncommitted work included) and drops ' + (database ?? 'its database') + '.',
453 confirmLabel: 'tear down',
454 }
455 }
456 const { branch } = removal.worktree
457 return {
458 title: 'Remove ' + branch + '?',
459 detail:
460 'Deletes the worktree folder (uncommitted work included)' +
461 (branch.startsWith('worktree-') ? ' and its throwaway branch.' : '. The branch stays.'),
462 confirmLabel: 'remove',
463 }
464}
465
466async function requestRemoval($: Engine) {
467 const view = await readView($)
468 if (view.focused === 'main') return $.ui.toast('The main checkout cannot be removed')
469 const removal = removalFor(view, view.focused)
470 if (!removal) return $.ui.toast('Move to a worktree first')
471
472 await update($, confirmingState, () => removal.key)
473 await update($, riskState, () => null)
474 const risk =
475 removal.kind === 'wop'
476 ? await worktreeRisk($, removal.environment.worktree, serviceLogs(removal.environment))
477 : await worktreeRisk($, removal.worktree.path, [])
478 await update($, riskState, () => risk)
479}
480
481async function cancelRemoval($: Engine) {
482 await update($, confirmingState, () => null)
483}
484
485async function tearDown($: Engine, environment: WopEnvironment) {
486 $.ui.toast('Tearing down ' + environment.branch + '…')
487 if (environment.current) await $.command.run({ command: 'cd', args: environment.project })
488 await runWop($, environment, 'down')
489 await saveAttached($, (await read($, attachedState)).filter((worktree) => worktree !== environment.worktree))
490 $.ui.toast(environment.branch + ' torn down')
491}
492
493async function removeWorktree($: Engine, worktree: WopPlainWorktree, main: string) {
494 $.ui.toast('Removing ' + worktree.branch + '…')
495 if (worktree.current) await $.command.run({ command: 'cd', args: main })
496 const result = await $.process.run(['git', 'worktree', 'remove', '--force', worktree.path], { cwd: main })
497 if (result.exitCode !== 0) return $.ui.toast('Could not remove ' + worktree.branch + ': ' + result.stderr.trim().split('\n')[0])
498 if (worktree.branch.startsWith('worktree-')) await $.process.run(['git', 'branch', '-D', worktree.branch], { cwd: main })
499 $.ui.toast(worktree.branch + ' removed')
500}
501
502async function confirmRemoval($: Engine) {
503 const removal = removalFor(await readView($), await read($, confirmingState))
504 await update($, confirmingState, () => null)
505 if (removal?.kind === 'wop') await tearDown($, removal.environment)
506 if (removal?.kind === 'plain') await removeWorktree($, removal.worktree, removal.main)
507 await reloadPicker($)
508}
509
510async function untrack($: Engine) {
511 const view = await readView($)
512 const environment = view.wop.find((candidate) => candidate.worktree === view.focused)
513 if (!environment?.attached || environment.current) return
514 await saveAttached($, (await read($, attachedState)).filter((worktree) => worktree !== environment.worktree))
515 await refresh($)
516 $.ui.toast('/exit will no longer ask about ' + environment.branch)
517}
518
519function statusText(environment: WopEnvironment) {
520 const status = health(environment.services)
521 if (environment.missing) return { color: 'error', note: '⚠ folder missing' } as const
522 if (status.running === environment.services.length) return { color: 'success', note: null } as const
523 if (status.running === 0) return { color: 'error', note: 'stopped' } as const
524 return { color: 'warning', note: status.label } as const
525}
526
527function details(environment: WopEnvironment) {
528 return [
529 '◆ ' + (environment.database ?? 'no database found'),
530 ...(environment.attached && !environment.current ? ['tracked by this chat'] : []),
531 ...(environment.otherChats > 0 ? [plural(environment.otherChats, 'other chat') + ' here'] : []),
532 ].join(' · ')
533}
534
535function hereTag(ui: Ui, current: boolean) {
536 const { Text } = ui
537 return current ? <Text color="magenta" bold wrap="truncate-end">◀ here</Text> : null
538}
539
540function sectionTitle(ui: Ui, glyph: string, title: string, first: boolean) {
541 const { Box, Text } = ui
542 return (
543 <Box flexDirection="row" columnGap={1} marginTop={first ? 0 : 1} marginBottom={1}>
544 <Text color="magenta" bold>{glyph}</Text>
545 <Text bold>{title}</Text>
546 </Box>
547 )
548}
549
550function rowButton(ui: Ui, key: string, label: string, current: boolean, onPress: () => void) {
551 const { Button } = ui
552 return current ? (
553 <Button key={key} label={fit(label, label.length)} plain autoFocus onPress={onPress} />
554 ) : (
555 <Button key={key} label={fit(label, label.length)} plain onPress={onPress} />
556 )
557}
558
559function portsLine(ui: Ui, environment: WopEnvironment) {
560 const { Box, Text } = ui
561 const ordered = [...environment.services].sort((left, right) => (left.name === 'web' ? -1 : right.name === 'web' ? 1 : 0))
562 return (
563 <Box flexDirection="row" columnGap={2} paddingLeft={2}>
564 {ordered.flatMap((service, index) => {
565 const color = service.running ? serviceColor(service) : null
566 const label = service.name + ' :' + service.port
567 return [
568 ...(index > 0 ? [<Text dimColor>·</Text>] : []),
569 color ? <Text color={color}>{label}</Text> : <Text dimColor>{label}</Text>,
570 ]
571 })}
572 </Box>
573 )
574}
575
576function wopRow($: Engine, ui: Ui, view: View, environment: WopEnvironment) {
577 const { Box, Text } = ui
578 const key = environment.worktree
579 const status = statusText(environment)
580 const isFocused = view.focused === key
581 return (
582 <Box
583 flexDirection="column"
584 borderStyle="round"
585 borderColor={isFocused || environment.current ? 'magenta' : 'inactive'}
586 borderDimColor={environment.current && !isFocused}
587 paddingX={1}
588 >
589 <Box flexDirection="row" columnGap={1}>
590 <Text color={status.color}>●</Text>
591 {rowButton(ui, key, environment.branch, environment.current, () => moveTo($, key, environment))}
592 {status.note ? <Text color={status.color}>{status.note}</Text> : null}
593 <Box flexGrow={1} />
594 {hereTag(ui, environment.current)}
595 </Box>
596 {portsLine(ui, environment)}
597 {isFocused ? (
598 <Box paddingLeft={2}>
599 <Text dimColor wrap="truncate-middle">{details(environment)}</Text>
600 </Box>
601 ) : null}
602 </Box>
603 )
604}
605
606function plainRow(ui: Ui, view: View, row: PlainRow) {
607 const { Box, Text } = ui
608 return (
609 <Box flexDirection="row" columnGap={1}>
610 <Text color="magenta" bold>{view.focused === row.key ? '❯ ' : ' '}</Text>
611 {row.current ? <Text color="magenta">{row.icon}</Text> : <Text dimColor>{row.icon}</Text>}
612 {rowButton(ui, row.key, row.label, row.current, row.onPress)}
613 {hereTag(ui, row.current)}
614 </Box>
615 )
616}
617
618function riskLine(ui: Ui, view: View, removal: Removal) {
619 const { Text } = ui
620 if (!view.risk) return <Text dimColor>Checking for unsaved work…</Text>
621 const found = warnings(view.risk, removal.kind === 'wop' ? removal.environment.otherChats : 0)
622 if (found.length === 0) return <Text color="success">✓ No uncommitted changes or unpushed commits</Text>
623 return <Text color="warning" bold wrap="wrap">{'⚠ ' + found.join(' · ')}</Text>
624}
625
626function confirmBar($: Engine, ui: Ui, view: View, removal: Removal) {
627 const { Box, Text, Button } = ui
628 const text = removalText(removal)
629 return (
630 <Box key="confirm" flexDirection="column" borderStyle="round" borderColor="error" paddingX={1}>
631 <Text color="red" bold>{text.title}</Text>
632 {riskLine(ui, view, removal)}
633 <Text dimColor wrap="wrap">{text.detail}</Text>
634 <Box flexDirection="row" columnGap={3}>
635 <Button key="confirm-yes" label={text.confirmLabel} hotkey="y" plain onPress={() => confirmRemoval($)} />
636 <Button key="confirm-no" label="cancel" hotkey="n" plain onPress={() => cancelRemoval($)} />
637 </Box>
638 </Box>
639 )
640}
641
642function footer($: Engine, ui: Ui, view: View) {
643 const { Box, Text, Button } = ui
644 const focusedEnvironment = view.wop.find((environment) => environment.worktree === view.focused)
645 const canUntrack = focusedEnvironment?.attached === true && !focusedEnvironment.current
646 return (
647 <Box flexDirection="row" columnGap={2} marginTop={1}>
648 <Text dimColor>↑↓ move</Text>
649 <Text dimColor>·</Text>
650 <Text dimColor>⏎ open</Text>
651 <Text dimColor>·</Text>
652 <Button key="remove" label="remove" plain dimColor hotkey="x" onPress={() => requestRemoval($)} />
653 {canUntrack ? <Text dimColor>·</Text> : null}
654 {canUntrack ? <Button key="untrack" label="untrack" plain dimColor hotkey="u" onPress={() => untrack($)} /> : null}
655 <Text dimColor>·</Text>
656 <Text dimColor>esc close</Text>
657 <Button key="close-picker" label="" plain action={CYCLE_ACTION} onPress={() => closePicker($)} />
658 </Box>
659 )
660}
661
662function pickerTree($: Engine, ui: Ui, view: View) {
663 const { Box, Text } = ui
664 if (!view.picker) return <Text dimColor>Reading worktrees…</Text>
665 const main = view.picker.main
666 if (!main) return <Text color="error">This chat is not inside a git repository.</Text>
667
668 const removal = removalFor(view, view.confirming)
669 const confirmUnder = (key: string) => (removal?.key === key ? [confirmBar($, ui, view, removal)] : [])
670 const plainRows: PlainRow[] = [
671 { key: 'main', icon: '⌂', label: 'main checkout', current: main.current, onPress: () => moveTo($, main.path, null) },
672 ...view.picker.plain.map((worktree) => ({
673 key: worktree.path,
674 icon: '⑂',
675 label: worktree.branch,
676 current: worktree.current,
677 onPress: () => moveTo($, worktree.path, null),
678 })),
679 ]
680
681 return (
682 <Box flexDirection="column" paddingRight={1}>
683 {view.wop.length > 0 ? sectionTitle(ui, '⎇', 'wop environments', true) : null}
684 {view.wop.flatMap((environment) => [wopRow($, ui, view, environment), ...confirmUnder(environment.worktree)])}
685 {sectionTitle(ui, '⑂', 'git worktrees', view.wop.length === 0)}
686 {plainRows.flatMap((row) => [plainRow(ui, view, row), ...confirmUnder(row.key)])}
687 {removal ? null : footer($, ui, view)}
688 </Box>
689 )
690}
691
692const KEEP = 'Keep running'
693const STOP = 'Stop services'
694const DOWN = 'Tear down'
695const REFRESH_MS = 3000
696
697function footerItems($: Engine, ui: Ui, environments: WopEnvironment[]) {
698 const { Text, Button } = ui
699 const current = environments.find((environment) => environment.current)
700 const web = current ? webService(current) : undefined
701 const status = current ? health(current.services) : null
702 const webColor = web ? serviceColor(web) : null
703 return [
704 <Text color="magenta" bold>⎇ </Text>,
705 ...(current && status && web
706 ? [
707 <Text color={status.color}>{status.dot}</Text>,
708 <Text bold>{current.branch.slice(current.branch.indexOf('/') + 1)}</Text>,
709 webColor ? <Text color={webColor}>{':' + web.port}</Text> : <Text>{':' + web.port}</Text>,
710 <Text dimColor>·</Text>,
711 ]
712 : []),
713 <Button
714 key="view-all-worktrees"
715 label={'View all worktrees (' + environments.length + ')'}
716 plain
717 action={CYCLE_ACTION}
718 onPress={() => togglePicker($)}
719 />,
720 <Text dimColor>⌃Q</Text>,
721 ]
722}
723
724async function lastCwdKey($: Engine) {
725 return 'cwd:' + (await $.session.id())
726}
727
728async function saveLastCwd($: Engine) {
729 await $.store.set(await lastCwdKey($), { cwd: await $.session.cwd(), at: await $.clock.now() })
730}
731
732async function returnToLastCwd($: Engine) {
733 if (!resuming) return
734 resuming = false
735 const saved = (await $.store.get(await lastCwdKey($))) as { cwd?: string } | undefined
736 const target = saved?.cwd
737 const cwd = await $.session.cwd()
738 if (!target || target === cwd || !(await $.fs.exists(target))) return
739 if ((await mainCheckout($, target)) !== (await mainCheckout($, cwd))) return
740 const environment = (await read($, environmentsState)).find((candidate) => insideWorktree(target, candidate.worktree)) ?? null
741 $.clock.after(RETURN_DELAY_MS, async () => {
742 await enter($, target, environment)
743 $.ui.toast('Back in ' + (environment?.branch ?? target))
744 })
745}
746
747function usedByChat(environment: WopEnvironment) {
748 return (environment.attached || environment.current) && !environment.claudeWorktree
749}
750
751async function handOffToShell($: Engine, reason: string) {
752 const path = await $.env.get('WOP_WORKTREES_EXIT_FILE')
753 if (!path || reason !== 'prompt_input_exit') return
754 const left = askedOnExit ? [] : (await read($, environmentsState)).filter(usedByChat)
755 await $.fs.write(path, JSON.stringify(left))
756}
757
758async function askOnExit($: Engine, environment: WopEnvironment) {
759 const risk = await worktreeRisk($, environment.worktree, serviceLogs(environment))
760 const question = exitQuestion(environment, warnings(risk, environment.otherChats))
761 return $.ui.ask(question, { header: 'wop', options: [KEEP, STOP, DOWN] })
762}
763
764export const register: Register = (on) => {
765 on('session.start', async ($, e, next) => {
766 try {
767 await $.command.register({ name: 'worktrees', description: 'Pick a wop environment or git worktree to move this chat into' })
768 } catch {}
769 await restoreAttached($)
770 await prune($).catch(() => {})
771 await refresh($)
772 started = true
773 await returnToLastCwd($).catch(() => {})
774 $.clock.every(REFRESH_MS, () => refresh($))
775 return next(e)
776 })
777
778 on('prompt.context', async ($, e, next) => {
779 const result = await next(e)
780 await refresh($)
781 const facts = await read($, announcedState)
782 if (!facts) return result
783 return { ...result, blocks: [...result.blocks, { name: 'wopEnvironment', text: facts }] }
784 }).catch(($, e, next) => next(e))
785
786 on('classic.SessionStart', async ($, e, next) => {
787 resuming = e.source === 'resume'
788 return next(e)
789 })
790
791 on('turn.complete', async ($, e, next) => {
792 const result = await next(e)
793 await saveLastCwd($).catch(() => {})
794 if (!e.agentId) $.clock.after(OFFER_DELAY_MS, () => offerMoves($).catch(() => {}))
795 return result
796 })
797
798 on('command.run', { command: 'cd' }, async ($, e, next) => {
799 const result = await next(e)
800 await saveLastCwd($).catch(() => {})
801 return result
802 }).catch(($, e, next) => next(e))
803
804 on('tool.call', async ($, e, next) => {
805 const result = await next(e)
806 await attachFromToolCall($, e).catch(() => {})
807 return result
808 }).catch(($, e, next) => next(e))
809
810 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
811 const modes = await next(e)
812 const environments = (await read($, environmentsState)).filter((environment) => environment.inProject || environment.current)
813 if (environments.length === 0) return modes
814 const ui = $.ui.resolve(e)
815 const { Box, Text } = ui
816 return (
817 <Box flexDirection="row" columnGap={1}>
818 {footerItems($, ui, environments)}
819 {modes && e.props.modes.length > 0 ? <Text dimColor>·</Text> : null}
820 {modes && e.props.modes.length > 0 ? modes : null}
821 </Box>
822 )
823 })
824
825 on('ui.render', { component: 'Pane', requestId: PICKER }, async ($, e) => pickerTree($, $.ui.resolve(e), await readView($)))
826
827 on('ui.focus', async ($, e, next) => {
828 if (e.requestId !== PICKER) return next(e)
829 const result = await next(e)
830 await update($, focusedState, () => e.element ?? null)
831 return result
832 }).catch(($, e, next) => next(e))
833
834 on('command.run', { command: 'worktrees' }, async ($) => {
835 await openPicker($)
836 return {}
837 })
838
839 on('command.run', { command: 'exit' }, async ($, e, next) => {
840 await refresh($)
841 const environments = await read($, environmentsState)
842 for (const environment of environments.filter(usedByChat)) {
843 let answer: string
844 try {
845 answer = await askOnExit($, environment)
846 } catch {
847 return { text: 'Exit cancelled.' }
848 }
849 if (answer === STOP) await runWop($, environment, 'stop')
850 if (answer === DOWN) await runWop($, environment, 'down')
851 }
852 askedOnExit = true
853 return next(e)
854 }).catch(($, e, next) => next(e))
855
856 on('session.end', async ($, e, next) => {
857 await handOffToShell($, e.reason).catch(() => {})
858 await endChat($).catch(() => {})
859 const exiting = e.reason === 'prompt_input_exit' || e.reason === 'other'
860 const registered = new Set(worktreesIn(exiting ? ((await readEntries($)) ?? []) : []))
861 for (const environment of await read($, environmentsState)) {
862 if (environment.claudeWorktree && registered.has(environment.worktree)) await runWop($, environment, 'stop')
863 }
864 return next(e)
865 })
866}
867hooks/text.ts 115 lines1import type { WopEnvironment, WopPlainWorktree, WopRisk, WopService } from '../types'
2
3export type RegistryEntry = {
4 project_path: string
5 branch: string
6 worktree_path: string
7 service: string
8 port: number
9 pid?: number
10 cmd?: string
11}
12
13export const WOP_COMMAND = /\bwop\s+(?:up|restart)\b/
14const WOP_BRANCH_COMMAND = /\bwop\s+(?:up|restart)\s+\\?["']?([\w./-]+)/g
15
16const SERVICE_COLORS: ReadonlyArray<readonly [RegExp, string]> = [
17 [/\brails\b|\bpuma\b/, '#CC342D'],
18 [/\bvite\b|pnpm dev|npm run dev|yarn dev/, '#646CFF'],
19 [/tailwind|\bcss\b/, '#2965F1'],
20 [/esbuild|webpack|\bnode\b/, '#F7DF1E'],
21]
22
23export const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1)
24const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
25export const insideWorktree = (path: string, worktree: string) => path === worktree || path.startsWith(worktree + '/')
26
27export function mentions(text: string, worktree: string) {
28 return new RegExp(escapeRegExp(basename(worktree)) + '(?![\\w-])').test(text)
29}
30
31export function branchesIn(text: string) {
32 return new Set([...text.matchAll(WOP_BRANCH_COMMAND)].map((match) => match[1]))
33}
34
35export function matchesToolCall(text: string, entry: RegistryEntry) {
36 return mentions(text, entry.worktree_path) || branchesIn(text).has(entry.branch)
37}
38
39export function worktreesIn(entries: readonly RegistryEntry[]) {
40 return [...new Set(entries.map((entry) => entry.worktree_path))]
41}
42
43export function sameJson(left: unknown, right: unknown) {
44 return JSON.stringify(left) === JSON.stringify(right)
45}
46
47export function parseGitWorktrees(porcelain: string): Omit<WopPlainWorktree, 'current'>[] {
48 return porcelain
49 .trim()
50 .split('\n\n')
51 .map((block) => {
52 const field = (name: string) => block.match(new RegExp('^' + name + ' (.+)$', 'm'))?.[1]
53 return { path: field('worktree') ?? '', branch: field('branch')?.replace('refs/heads/', '') ?? 'detached HEAD' }
54 })
55 .filter((worktree) => worktree.path !== '')
56}
57
58export function health(services: readonly WopService[]) {
59 const running = services.filter((service) => service.running).length
60 const color = running === services.length ? 'success' : running === 0 ? 'error' : 'warning'
61 return { running, color, dot: running === 0 ? '○' : '●', label: running + '/' + services.length + ' running' } as const
62}
63
64export function serviceColor(service: WopService) {
65 return SERVICE_COLORS.find(([pattern]) => pattern.test(service.cmd))?.[1] ?? null
66}
67
68export function webService(environment: WopEnvironment) {
69 return environment.services.find((service) => service.name === 'web') ?? environment.services[0]
70}
71
72export function plural(count: number, word: string) {
73 return count + ' ' + word + (count === 1 ? '' : 's')
74}
75
76export function fit(text: string, width: number) {
77 const room = Math.max(width, 8)
78 return text.length > room ? text.slice(0, room - 1) + '…' : text.padEnd(room)
79}
80
81export function warnings(risk: WopRisk | null, otherChats: number) {
82 const found: string[] = []
83 if (otherChats > 0) found.push(otherChats === 1 ? 'another chat is working in it' : otherChats + ' other chats are working in it')
84 if (risk?.failed) found.push('could not check it for unsaved work')
85 if (risk && risk.changes > 0) found.push(plural(risk.changes, 'uncommitted change'))
86 if (risk && risk.unpushed > 0) found.push(plural(risk.unpushed, 'commit') + ' not on the remote')
87 return found
88}
89
90export function exitQuestion(environment: WopEnvironment, found: readonly string[]) {
91 const ports = environment.services.map((service) => service.name + ' :' + service.port).join(', ')
92 const warning = found.length > 0 ? ' ⚠ ' + found.join(' · ') + '.' : ''
93 return (
94 environment.branch + ' still has its worktree, database ' + (environment.database ?? '(unknown)') +
95 ' and ports (' + ports + ').' + warning + ' What should happen to it before you exit?'
96 )
97}
98
99export function environmentFacts(environment: WopEnvironment) {
100 const services = [...environment.services].sort((left, right) => (left.name === 'web' ? -1 : right.name === 'web' ? 1 : 0))
101 const web = webService(environment)
102 return [
103 'This chat is in the wop environment for branch ' + environment.branch + ', worktree ' + environment.worktree + '.',
104 'Its services: ' + services.map((service) => service.name + ' on port ' + service.port).join(', ') + '.' +
105 (web ? ' The app is at http://localhost:' + web.port + '. Use these ports, not framework defaults like 3000.' : ''),
106 'Its database: ' + (environment.database ?? 'unknown (see DATABASE_URL in its .env)') + '.',
107 'Other wop environments belong to other branches and chats: never stop, restart or kill their processes or ports.',
108 'Manage this one with `wop restart|stop ' + environment.branch + '`, run from the main checkout ' + environment.project + '.',
109 ].join('\n')
110}
111
112export function leftEnvironmentNote(cwd: string) {
113 return 'This chat is no longer in a wop environment. It now works in ' + cwd + '; earlier wop ports and database facts no longer apply.'
114}
115types/index.d.ts 44 lines1export type WopService = { name: string; port: number; cmd: string; running: boolean }
2
3export type WopEnvironment = {
4 worktree: string
5 branch: string
6 project: string
7 database: string | null
8 missing: boolean
9 claudeWorktree: boolean
10 current: boolean
11 attached: boolean
12 inProject: boolean
13 otherChats: number
14 services: WopService[]
15}
16
17export type WopPlainWorktree = { path: string; branch: string; current: boolean }
18
19export type WopPicker = {
20 main: { path: string; current: boolean } | null
21 plain: WopPlainWorktree[]
22}
23
24export type WopRisk = { changes: number; unpushed: number; failed: boolean }
25
26export type WopMention = { text: string; until: number }
27
28declare module 'claude-code' {
29 interface PluginState {
30 'wop-worktrees': {
31 environments: WopEnvironment[]
32 attached: string[]
33 pending: WopMention[]
34 picker: WopPicker | null
35 focused: string | null
36 confirming: string | null
37 risk: WopRisk | null
38 announced: string | null
39 offers: string[]
40 offered: string[]
41 }
42 }
43}
44