SLOPSHOPPER

wt-caddy-status

Dev servers of the session's worktree: status line entry and a /servers pane to start, stop, restart and open them

newpanecommandtoaststatusprocess
v0.2.0no licenseupdated 2026-10-06ptbrowne/worktrunk-caddy/mods/wt-caddy-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wt-caddy-status
│ ┃ Dev servers ✕ › fix the failing auth test and add an audit log call │ ┃ No dev servers defined for this worktree. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /servers │ ⎿ wt-caddy-status: Dev servers pane opened. Up/Down move, Enter pr │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dev servers
No dev servers defined for this worktree.
README

worktrunk-caddy

Routes git-worktree dev servers through Caddy and shows them on a live dashboard. Companion to worktrunk (wt, docs), which owns creating worktrees, copying files and starting servers. The command is wt-caddy, and it only owns URLs.

Install

Needs Caddy and Node 22.6+ (brew install caddy node). Add worktrunk if you want hooks to do the registering.

git clone https://github.com/ptbrowne/worktrunk-caddy ~/code/wt-caddy
mkdir -p ~/bin
cat > ~/bin/wt-caddy <<'SH'
#!/bin/sh
# The flag makes Node 22.6-23.5 run the .ts file too; newer versions ignore it.
exec node --experimental-strip-types --disable-warning=ExperimentalWarning "$HOME/code/wt-caddy/wt-caddy.ts" "$@"
SH
chmod +x ~/bin/wt-caddy

~/bin must be on your PATH. The shim only has to find node. If node isn't on the PATH of the shell that runs it (a version manager, say), add its directory to the shim's PATH. Everything else wt-caddy runs gets its PATH from the config file. Update with git pull. Once the command is on PATH, wt caddy ... works too.

Quick usage

wt-caddy service        # starts Caddy and the dashboard

# in a worktree, register its dev server on a stable port and start it
wt-caddy add myrepo my-branch 4310 --cmd 'pnpm dev --port 4310' --start
# -> http://my-branch.myrepo.localhost:8080

wt-caddy ls             # routes, whether each port is listening
wt-caddy ui             # interactive list: start/stop/restart, open, tail logs
wt-caddy rm myrepo my-branch --kill

The dashboard is at http://wt.localhost:8080. Routes whose worktree directory has been deleted are pruned by ls, ui and the dashboard, so removing a worktree with plain git or rm -rf doesn't leave a route behind.

With worktrunk, wt-caddy add goes in a post-start hook and wt-caddy rm --kill in a pre-remove hook (see the worktrunk hook documentation and config documentation). Ready-made hooks: skills/setup-wt/SKILL.md.

How it differs from portree

portree is the closest tool. It also gives each worktree <branch>.localhost URLs and a terminal dashboard. The differences:

wt-caddyportree
ProxyCaddy, one instance per machineBuilt-in Go proxy
ConfigNone in the repo; routes are registered by commands (hooks).portree.toml at the repo root
ScopeOne registry for every repo on the machinePer repo, state in .portree/
Starting serversStarts and logs what you registered with --cmd; worktrunk hooks do the restportree up starts the services in the config
PortsYou pass the port (worktrunk hooks use a hash of the branch name)Hash of branch and service, with probing for a free port
Worktree creationExpects worktrunk to create and remove worktreesCreate worktrees yourself with git worktree add
HTTPSNoYes, generated certificates
DashboardWeb page plus terminal UITerminal UI

I'd pick portree if you want a self-contained, per-repo config with no other tools. I'd pick wt-caddy if you already use worktrunk and want every repo's dev servers behind one Caddy and one dashboard.

Reference

wt-caddy add <repo> <branch> <port> [--service name] [--path dir] [--rewrite-host] [--cmd '<command>'] [--start]
wt-caddy start|stop|restart <repo> <branch> [--service name]
wt-caddy rm  <repo> <branch> [--service name] [--kill]
wt-caddy ls [--json]
wt-caddy logs <repo> <branch> [--service name] [--open]   # path, or show it via config logs.open
wt-caddy ui       # interactive list: start/stop/restart, open the URL, tail logs
wt-caddy gc        # drop routes whose worktree directory is gone (ls, ui and the dashboard do this too)
wt-caddy service   # start Caddy and the dashboard if they aren't running
  • URL: <branch>.<repo>.localhost:8080, or <service>.<branch>.<repo>.localhost:8080. *.localhost resolves to loopback in browsers, so no DNS setup.
  • Dashboard: http://wt.localhost:8080 (live over SSE). Started automatically by add.
  • Registry: ~/.local/state/wt-caddy/routes.json. Every change replaces the whole Caddy server config.
  • --rewrite-host sends Host: localhost:<port> upstream, for servers with a host allowlist (Storybook).
  • --kill on rm stops whatever listens on the removed ports.

Node 22.6+ runs the .ts file directly; the shim passes --experimental-strip-types. Hooks: see skills/setup-wt/SKILL.md. More in docs/.

Config

~/.config/wt-caddy/config.jsonc (JSON with comments), optional:

{
  // Added after the existing PATH for everything wt-caddy runs. Replaces the defaults when set.
  "path": ["~/.local/share/fnm/aliases/default/bin", "~/Library/pnpm/bin", "/opt/homebrew/bin"],
  "logs": {
    // {log} = file, {title} = label. Leave out to just print the path.
    "open": "kitten @ launch --type=window --cwd=current --title {title} tail -f {log}"
  }
}

The ~/bin/wt-caddy shim only has to find node; the rest of PATH comes from this file. The UI's l key uses the same logs.open. For tmux use tmux split-window -v "tail -f {log}". Logs exist for services started by wt-caddy (add --start, start, restart) in ~/.local/state/wt-caddy/logs/. Each start begins a fresh log and keeps the previous run once as <name>.log.1; rm and gc delete a removed route's logs. A single run's log is not rotated.

Source 3 files
hooks/register.tsx 135 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { notRegistered, parseDefined, routesFor, statusText, type Defined, type Route } from './format'
5
6const PANE = 'servers'
7const routes = atom({ plugin: 'wt-caddy-status', key: 'routes' } as const, [])
8const defined = atom({ plugin: 'wt-caddy-status', key: 'defined' } as const, [])
9
10
11let bin = ''
12let lastStatus: string | undefined
13let lastRoutes = ''
14
15// wt-caddy checks which ports listen, so one spawn per refresh gives routes and liveness.
16const refresh = async ($: EngineInterface) => {
17  try {
18    const cwd = await $.session.cwd()
19    const ran = await $.process.run([bin, 'ls', '--json'], { timeoutMs: 5000 })
20    const all = JSON.parse(ran.stdout) as Route[]
21    const mine = routesFor(all, cwd)
22
23    const snapshot = JSON.stringify(mine)
24    if (snapshot !== lastRoutes) {
25      lastRoutes = snapshot
26      await update($, routes, () => mine)
27    }
28
29    const text = statusText(all, cwd)
30    const shown = text === undefined ? undefined : `${text} · /servers`
31    if (shown !== lastStatus) {
32      lastStatus = shown
33      $.ui.status(shown)
34    }
35  } catch {
36    // wt-caddy missing or registry unreadable: keep what is shown
37  }
38}
39
40// The services this worktree's post-start hooks define, registered or not.
41const loadDefined = async ($: EngineInterface) => {
42  try {
43    const ran = await $.process.run([bin, 'hooks'], { timeoutMs: 10000 })
44    const hooks = JSON.parse(ran.stdout) as { name: string; expanded: string }[]
45    await update($, defined, () => parseDefined(hooks))
46  } catch {
47    await update($, defined, () => [])
48  }
49}
50
51// Shows the service's log the way config.jsonc says (a kitty split, a tmux pane, ...); without that, toasts the path.
52const showLog = async ($: EngineInterface, r: Route) => {
53  const ran = await $.process.run([bin, 'logs', r.repo, r.branch, '--service', r.service, '--open'], { timeoutMs: 10000 })
54  const text = (ran.exitCode === 0 ? ran.stdout : ran.stderr).trim()
55  if (text) $.ui.toast(text)
56}
57
58// Runs the one hook that registers and starts a defined service.
59const startDefined = async ($: EngineInterface, d: Defined) => {
60  await $.process.run([bin, 'hook', d.name], { timeoutMs: 20000 })
61  await refresh($)
62  await loadDefined($)
63}
64
65// verb is a wt-caddy subcommand; the pane redraws once the refresh sees the new state.
66const run = async ($: EngineInterface, verb: string, r: Route) => {
67  await $.process.run([bin, verb, r.repo, r.branch, '--service', r.service], { timeoutMs: 20000 })
68  await refresh($)
69}
70
71// Asking for the keyboard is only a request: the surface refuses it while the composer still holds
72// the typed "/servers", so this is called again once the command has run.
73const openFocused = async ($: EngineInterface) => {
74  const opened = await $.ui.open({ id: PANE, title: 'Dev servers', focus: true, closeOnEscape: true })
75  if (!opened.isPlaced) $.ui.toast(`/servers: pane not shown (${opened.reason})`)
76}
77
78export const register: Register = on => {
79  on('session.start', async ($, e, next) => {
80    bin = `${await $.env.get('HOME')}/bin/wt-caddy`
81    await $.command.register({ name: 'servers', description: 'Start, stop and open this worktree\'s dev servers' })
82    $.clock.every(3000, () => refresh($))
83    await refresh($)
84    return next(e)
85  })
86
87  on('command.run', { command: 'servers' }, async $ => {
88    await refresh($)
89    await loadDefined($)
90    await openFocused($)
91    $.clock.after(200, () => openFocused($))
92    return { text: 'Dev servers pane opened. Up/Down move, Enter presses, Esc closes. If keys do not reach it, press ctrl+x then Tab.' }
93  })
94
95  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
96    const { Box, Text, Button } = $.ui.resolve(e)
97    const list = await read($, routes)
98    const idle = notRegistered(await read($, defined), list)
99
100    if (list.length === 0 && idle.length === 0) return <Text dimColor>No dev servers defined for this worktree.</Text>
101
102    return (
103      <Box flexDirection="column">
104        {list.map(r => (
105          <Box key={`svc:${r.service}`} flexDirection="column">
106          <Box key={`row:${r.service}`} flexDirection="row">
107            <Text>
108              {r.up ? '●' : '○'} {(r.service === 'main' ? 'dev' : r.service).padEnd(12)}
109            </Text>
110            <Button
111              key={`toggle:${r.service}`}
112              label={r.up ? 'stop' : 'start'}
113              autoFocus={r === list[0] ? true : undefined}
114              onPress={() => run($, r.up ? 'stop' : 'start', r)}
115            />
116          </Box>
117          <Box key={`logrow:${r.service}`} flexDirection="row">
118            <Text dimColor>{'  '.padEnd(16)}</Text>
119            <Button key={`logs:${r.service}`} label="logs" dimColor onPress={() => showLog($, r)} />
120          </Box>
121          </Box>
122        ))}
123        {idle.map(d => (
124          <Box key={`idle:${d.service}`} flexDirection="row">
125            <Text dimColor>○ {(d.service === 'main' ? 'dev' : d.service).padEnd(12)}</Text>
126            <Button key={`startdef:${d.service}`} label="start" onPress={() => startDefined($, d)} />
127            <Text dimColor> not started</Text>
128          </Box>
129        ))}
130        <Text dimColor>Up/Down move, Enter presses, Esc closes</Text>
131      </Box>
132    )
133  })
134}
135
hooks/format.ts 31 lines
1export type Route = { repo: string; branch: string; service: string; host: string; path: string; up: boolean };
2
3export const CADDY_PORT = 8080;
4
5// Routes of the worktree the session is in; the session may sit in a subdirectory of it.
6export const routesFor = (routes: readonly Route[], cwd: string): Route[] =>
7  routes
8    .filter((r) => cwd === r.path || cwd.startsWith(`${r.path}/`))
9    .sort((a, b) => Number(b.service === "main") - Number(a.service === "main") || a.service.localeCompare(b.service));
10
11// "● dev  ● storybook  ○ server · http://branch.repo.localhost:8080", or undefined when the worktree has no routes.
12export const statusText = (routes: readonly Route[], cwd: string): string | undefined => {
13  const mine = routesFor(routes, cwd);
14  if (mine.length === 0) return undefined;
15  const dots = mine.map((r) => `${r.up ? "●" : "○"} ${r.service === "main" ? "dev" : r.service}`).join("  ");
16  return `${dots} · http://${mine[0].host}:${CADDY_PORT}`;
17};
18
19export type Defined = { name: string; service: string };
20
21// The services a repo's post-start hooks would register: `wt hook show post-start --expanded --format json`
22// entries that run `wt-caddy add`, the service taken from `--service` (none means the main one).
23export const parseDefined = (hooks: readonly { name: string; expanded: string }[]): Defined[] =>
24  hooks
25    .filter((h) => h.expanded.startsWith("wt-caddy add "))
26    .map((h) => ({ name: h.name, service: /--service (\S+)/.exec(h.expanded)?.[1] ?? "main" }));
27
28// Defined services with no route yet in this worktree.
29export const notRegistered = (defined: readonly Defined[], routes: readonly Route[]): Defined[] =>
30  defined.filter((d) => !routes.some((r) => r.service === d.service));
31
types/index.d.ts 10 lines
1export type Route = { repo: string; branch: string; service: string; host: string; path: string; up: boolean }
2
3export type Defined = { name: string; service: string }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'wt-caddy-status': { routes: Route[]; defined: Defined[] }
8  }
9}
10