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

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.
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.
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.
portree is the closest tool. It also gives each worktree <branch>.localhost URLs and a terminal dashboard. The differences:
| wt-caddy | portree | |
|---|---|---|
| Proxy | Caddy, one instance per machine | Built-in Go proxy |
| Config | None in the repo; routes are registered by commands (hooks) | .portree.toml at the repo root |
| Scope | One registry for every repo on the machine | Per repo, state in .portree/ |
| Starting servers | Starts and logs what you registered with --cmd; worktrunk hooks do the rest | portree up starts the services in the config |
| Ports | You pass the port (worktrunk hooks use a hash of the branch name) | Hash of branch and service, with probing for a free port |
| Worktree creation | Expects worktrunk to create and remove worktrees | Create worktrees yourself with git worktree add |
| HTTPS | No | Yes, generated certificates |
| Dashboard | Web page plus terminal UI | Terminal 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.
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
<branch>.<repo>.localhost:8080, or <service>.<branch>.<repo>.localhost:8080. *.localhost resolves to loopback in browsers, so no DNS setup.http://wt.localhost:8080 (live over SSE). Started automatically by add.~/.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/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.
hooks/register.tsx 135 lines1import { 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}
135hooks/format.ts 31 lines1export 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));
31types/index.d.ts 10 lines1export 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