SLOPSHOPPER

wop-worktrees

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…

newpanespinnerguardcommandprompt
v?MITupdated 2026-10-09FLampa/wop-worktrees
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wop-worktrees
│ ┃ wop worktrees ✕ › fix the failing auth test and add an audit log call │ ┃ ⑂ git worktrees │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⌂ main checkout ◀ here ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ↑↓ move · ⏎ open · x: remove · esc clo ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /worktrees │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · wop worktrees
⑂ git worktrees ⌂ main checkout ◀ here ↑↓ move · ⏎ open · x: remove · esc close
README

wop-worktrees

CI License: MIT Claude Code 2.1.295+

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.

Contents

Install

Requirements:

  • Claude Code 2.1.295 or newer (mods are early access and change between releases)
  • wop 3.x on your PATH
  • jq and bash, for the optional shell wrapper and claude -w scripts

At 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.

Features

See and switch environments

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.

Claude knows the environment

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.

Clean up when you exit

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">

Resume where you left off

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.

Usage

Open the worktree list with /worktrees, a click on the footer button, or Ctrl+Q.

KeyIn the worktree list
↑ ↓Move between environments and worktrees
⏎Move the chat there
xTear down the wop environment (wop down) or remove the git worktree, after asking
uStop tracking an environment the chat only looked at, so /exit skips it
EscClose the list

/exit asks about every environment the chat used. Esc at that prompt cancels the exit.

Optional setup

Ctrl+Q for the worktree list

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.

Ask after Ctrl+C

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 wop

scripts/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 }] }
    ]
  }
}

Limitations

  • No prompt inside Claude Code on Ctrl+C or Ctrl+D. Only /exit asks there. The shell wrapper asks afterwards, and claude -w chats get Claude Code's own keep/remove prompt.
  • No Down-arrow access to the footer button. Use a click, /worktrees or Ctrl+Q.
  • Clicking needs fullscreen rendering ("tui": "fullscreen").
  • Other chats are seen only if they run this mod. Each chat records its directory under ~/.claude/wop-worktrees/chats/.
  • Tear down is wop down. It deletes the worktree folder, uncommitted work included, and drops its databases. The git branch stays.

Development

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.

License

MIT

Source 3 files
hooks/register.tsx 867 lines
1import { 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}
867
hooks/text.ts 115 lines
1import 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}
115
types/index.d.ts 44 lines
1export 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