Open, start and stop this project's browser apps from the Git panel

Little mods for Claude Code: panels that keep the stuff you'd normally go check next to the chat, and helpers that do the chores for you.
You spend all day in Claude Code. But the small stuff keeps pulling you out of it: checking git, rescuing a session that got too long, doing the same cleanup by hand.
These mods put that stuff inside Claude Code. Some draw a live panel next to the chat. Some do a chore for you at the right moment. You keep your eyes on the work.
Each one installs on its own, so you take only what you want.
Add the marketplace once, then install a mod:
/plugin marketplace add spardutti/claude-mods
/plugin install branch-status@spardutti-mods
/plugin install auto-handoff@spardutti-mods
/plugin install handoff-pickup@spardutti-mods
/plugin install blocked-run@spardutti-mods
/plugin install dev-apps@spardutti-mods
Install any you like. Each one starts working on the next session start.
Each mod has its own page with the full story.
| Mod | What it does |
|---|---|
| branch-status | A panel with your branch, develop and main as a tiny git-flow graph, so you see what's waiting to ship without running git log. |
| auto-handoff | When the context gets too full, it warns you, counts down, then hands the work to a fresh session. |
| handoff-pickup | Reminds you of a handoff you left behind. /pickup picks it up, /pickup done deletes it. |
| blocked-run | When auto mode blocks a command, it shows above the prompt with a button to run it yourself. |
| dev-apps | Under the Git panel: this project's apps, a link to open each, and Start and Stop. Stops them when you close Claude. |
/plugin marketplace update spardutti-mods
/plugin update branch-status@spardutti-mods
/plugin update auto-handoff@spardutti-mods
/plugin update handoff-pickup@spardutti-mods
/plugin update blocked-run@spardutti-mods
/plugin update dev-apps@spardutti-mods
/reload-pluginshooks/register.tsx 141 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Project } from '../types'
5import { LISTENING, READ_REGISTRY, checkIn, checkOut, startCommands, stopCommand } from './commands'
6import { COMPOSE_FILES, launcherFor } from './launch'
7import { drawApps, splitPane } from './panel'
8import type { PaneInput } from './panel'
9import { projectName } from './project'
10import { browserApps } from './registry'
11import { listeningPorts } from './running'
12
13const GIT_PANE = 'branch-status'
14// Keys the two mods tag their drawings with, so whichever runs first can stack both.
15const GIT_GRAPH = 'git-graph'
16const APPS_SECTION = 'dev-apps'
17const PANE = 'dev-apps'
18const POLL_MS = 3_000
19const project = atom({ plugin: 'dev-apps', key: 'project' } as const, null as Project | null)
20
21async function output($: EngineInterface, argv: string[], cwd?: string) {
22 const { exitCode, stdout } = await $.process.run(argv, { cwd }).catch(() => ({ exitCode: 1, stdout: '' }))
23 return exitCode === 0 ? stdout : undefined
24}
25
26const isTagged = (tree: unknown, key: string) => (tree as { props?: { key?: string } } | undefined)?.props?.key === key
27
28const readFile = ($: EngineInterface, path: string) => $.fs.read(path).catch(() => undefined)
29
30async function load($: EngineInterface, cwd: string): Promise<Project | null> {
31 const commonDir = await output($, ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], cwd)
32 const root = (await output($, ['git', 'rev-parse', '--show-toplevel'], cwd))?.trim() || cwd
33 const name = projectName(commonDir, cwd)
34 const apps = browserApps((await output($, READ_REGISTRY)) ?? '', name)
35 if (!apps.length) return null
36 const files = (await $.fs.list(root).catch(() => [])).map(entry => entry.name)
37 const composeFile = COMPOSE_FILES.find(file => files.includes(file))
38 const sources = {
39 compose: composeFile ? await readFile($, `${root}/${composeFile}`) : undefined,
40 env: await readFile($, `${root}/.env`),
41 pkg: files.includes('package.json') ? await readFile($, `${root}/package.json`) : undefined,
42 files,
43 }
44 const launcher = launcherFor(sources, apps.map(app => app.port))
45 return { name, root, apps, launcher, listening: [] }
46}
47
48async function poll($: EngineInterface) {
49 const shown = await read($, project)
50 if (!shown) return
51 void $.process.run(checkIn(shown.name, await $.session.id())).catch(() => undefined)
52 const listening = listeningPorts((await output($, LISTENING)) ?? '')
53 const isAllUp = shown.apps.every(app => listening.includes(app.port))
54 const isAllDown = !shown.apps.some(app => listening.includes(app.port))
55 await update($, project, now => now && {
56 ...now,
57 listening,
58 startedAt: isAllUp ? undefined : now.startedAt,
59 stoppingAt: isAllDown ? undefined : now.stoppingAt,
60 })
61}
62
63async function start($: EngineInterface) {
64 const shown = await read($, project)
65 if (!shown?.launcher) return
66 const startedAt = await $.clock.now()
67 await update($, project, now => now && { ...now, startedAt, stoppingAt: undefined })
68 for (const argv of startCommands(shown.name, shown.launcher)) await $.process.run(argv, { cwd: shown.root })
69}
70
71async function stop($: EngineInterface) {
72 const shown = await read($, project)
73 if (!shown?.launcher) return
74 const stoppingAt = await $.clock.now()
75 await update($, project, now => now && { ...now, startedAt: undefined, stoppingAt })
76 await $.process.run(stopCommand(shown.name, shown.launcher), { cwd: shown.root })
77}
78
79async function stopIfLast($: EngineInterface) {
80 const shown = await read($, project)
81 if (!shown?.launcher) return
82 const left = await output($, checkOut(shown.name, await $.session.id()))
83 if (left?.trim() === 'last') await $.process.run(stopCommand(shown.name, shown.launcher), { cwd: shown.root })
84}
85
86async function apps($: EngineInterface, e: PaneInput) {
87 const shown = await read($, project)
88 if (!shown) return undefined
89 const actions = { start: () => void start($), stop: () => void stop($) }
90 return drawApps($, e, { project: shown, now: await $.clock.now(), actions })
91}
92
93async function startSession($: EngineInterface, cwd: string) {
94 await $.command.register({ name: 'apps', description: "Open, start and stop this project's browser apps" })
95 void load($, cwd).then(async found => {
96 await update($, project, () => found)
97 await poll($)
98 })
99 $.clock.every(POLL_MS, () => void poll($))
100}
101
102async function openPanel($: EngineInterface) {
103 if (!(await read($, project))) return { text: 'No browser apps for this project in dev-ports.' }
104 await $.ui.open({ id: PANE, title: 'Apps' })
105
106 return { text: 'Apps panel opened.' }
107}
108
109export const register: Register = on => {
110 on('session.start', async ($, e, next) => {
111 await startSession($, e.cwd)
112
113 return next(e)
114 })
115
116 on('command.run', { command: 'apps' }, openPanel)
117
118 // A /clear or a resume keeps Claude running, so the apps stay up.
119 on('session.end', async ($, e, next) => {
120 if (e.reason !== 'clear' && e.reason !== 'resume') await stopIfLast($)
121
122 return next(e)
123 })
124
125 on('ui.render', { component: 'Pane', requestId: GIT_PANE }, async ($, e, next) => {
126 const below = await next(e)
127 const drawn = await apps($, e)
128 if (!drawn) return below
129 if (isTagged(below, GIT_GRAPH)) return splitPane($, e, { top: below, apps: drawn })
130 const { Box } = $.ui.resolve(e)
131
132 return <Box key={APPS_SECTION} flexDirection="column">{drawn}</Box>
133 })
134
135 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
136 const { Text } = $.ui.resolve(e)
137
138 return (await apps($, e)) ?? <Text dimColor>No browser apps for this project in dev-ports.</Text>
139 })
140}
141hooks/commands.ts 39 lines1import type { Launcher } from '../types'
2
3// Every script takes the project as $1; its files live in this folder.
4const CACHE = '"$HOME/.cache/dev-apps/$1"'
5
6export const LOG_FOLDER = '~/.cache/dev-apps'
7
8export const READ_REGISTRY = ['sh', '-c', 'cat "${DEV_PORTS_REGISTRY:-$HOME/.config/dev-ports/registry.tsv}"']
9
10export const LISTENING = ['ss', '-ltnH']
11
12// setsid -f lets the app outlive Claude; the exec line frees the pipes so the call returns at once.
13const detached = (script: string, ...args: string[]) => ['setsid', '-f', 'sh', '-c', `mkdir -p ${CACHE} && ${script}`, 'sh', ...args]
14
15const compose = (verb: string) => `exec >> ${CACHE}/docker.log 2>&1 < /dev/null && exec docker compose ${verb}`
16
17const composeWatch = `exec >> ${CACHE}/docker.log 2>&1 < /dev/null && echo $$ > ${CACHE}/watch.pid && exec docker compose watch`
18
19const npmRun = `exec > ${CACHE}/"$2".log 2>&1 < /dev/null && echo $$ > ${CACHE}/"$2".pid && exec "$3" run "$2"`
20
21const KILL_STARTED = `for f in ${CACHE}/*.pid; do [ -f "$f" ] && kill -TERM "-$(cat "$f")" 2>/dev/null; rm -f "$f"; done`
22
23export function startCommands(project: string, launcher: Launcher): string[][] {
24 if (launcher.kind === 'docker') return [detached(launcher.watch ? composeWatch : compose('up -d'), project)]
25 return launcher.scripts.map(script => detached(npmRun, project, script, launcher.manager))
26}
27
28export function stopCommand(project: string, launcher: Launcher): string[] {
29 if (launcher.kind === 'docker') return detached(`${KILL_STARTED}; ${compose('down')}`, project)
30 return ['sh', '-c', KILL_STARTED, 'sh', project]
31}
32
33export const checkIn = (project: string, session: string) =>
34 ['sh', '-c', `mkdir -p ${CACHE}/sessions && touch ${CACHE}/sessions/"$2"`, 'sh', project, session]
35
36// Prints "last" when no other session checked in for this project in the last 2 minutes.
37export const checkOut = (project: string, session: string) =>
38 ['sh', '-c', `rm -f ${CACHE}/sessions/"$2"; find ${CACHE}/sessions -type f -mmin -2 | grep -q . || echo last`, 'sh', project, session]
39hooks/launch.ts 47 lines1import type { Launcher } from '../types'
2
3export const COMPOSE_FILES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']
4
5const LOCKS: [string, string][] = [['pnpm-lock.yaml', 'pnpm'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['yarn.lock', 'yarn']]
6
7export type Sources = { compose?: string; env?: string; pkg?: string; files: string[] }
8
9export function envOf(text = ''): Record<string, string> {
10 const pairs = text.split('\n').map(line => /^\s*(?:export\s+)?(\w+)=["']?([^"'\r]*)["']?\s*$/.exec(line))
11 return Object.fromEntries(pairs.filter(m => m !== null).map(m => [m[1], m[2]]))
12}
13
14// ${VAR}, ${VAR:-default} and ${VAR-default}, as compose fills them from .env.
15const fill = (text: string, env: Record<string, string>) =>
16 text.replace(/\$\{(\w+)(?::?-([^}]*))?\}/g, (_, name: string, fallback = '') => env[name] ?? fallback)
17
18// "8080:80", "127.0.0.1:8080:80" or "8080:80/tcp": the host port is the one before the container's.
19const HOST_PORT = /^-\s*["']?(?:[\d.]+:)?(\d+):\d+(?:\/\w+)?["']?\s*$/
20
21export function composePorts(yaml: string, env: Record<string, string>): number[] {
22 return fill(yaml, env)
23 .split('\n')
24 .map(line => HOST_PORT.exec(line.trim()))
25 .filter(m => m !== null)
26 .map(m => Number(m[1]))
27}
28
29export function scriptsFor(pkg: string, ports: number[]): string[] {
30 const scripts: Record<string, string> = JSON.parse(pkg).scripts ?? {}
31 const names = Object.keys(scripts).sort((a, b) => Number(b === 'dev') - Number(a === 'dev'))
32 return ports
33 .map(port => names.find(name => new RegExp(`(^|\\D)${port}(\\D|$)`).test(scripts[name] ?? '')))
34 .filter(name => name !== undefined)
35}
36
37const WATCH = /^\s*develop:\s*\n\s*watch:/m
38
39export const managerOf = (files: string[]) => LOCKS.find(([lock]) => files.includes(lock))?.[1] ?? 'npm'
40
41export function launcherFor(sources: Sources, ports: number[]): Launcher | undefined {
42 const { compose, env, pkg, files } = sources
43 if (compose && composePorts(compose, envOf(env)).some(port => ports.includes(port))) return { kind: 'docker', watch: WATCH.test(compose) }
44 const scripts = pkg ? [...new Set(scriptsFor(pkg, ports))] : []
45 return scripts.length ? { kind: 'node', manager: managerOf(files), scripts } : undefined
46}
47hooks/panel.tsx 64 lines1import type { EngineInterface, RenderChildren, RenderInputOf, RenderSurface } from 'claude-code'
2
3import type { Project } from '../types'
4import { LOG_FOLDER } from './commands'
5import { isBusy, statusOf } from './running'
6import type { Status } from './running'
7
8export type PaneInput = { [S in RenderSurface]: RenderInputOf<'Pane', S> }[RenderSurface]
9
10export type Actions = { start: () => void; stop: () => void }
11
12export type View = { project: Project; now: number; actions: Actions }
13
14const COLOR: Record<Status, string | undefined> = { running: 'green', starting: 'yellow', stopping: 'yellow', failed: 'red', stopped: undefined }
15
16function controls($: EngineInterface, e: PaneInput, { project, now, actions }: View) {
17 const { Box, Button, Text } = $.ui.resolve(e)
18 if (!project.launcher) return <Text dimColor>No start command found. Open only.</Text>
19 const isUp = project.apps.some(app => project.listening.includes(app.port))
20 const isDown = project.apps.some(app => !project.listening.includes(app.port))
21 const isPressed = isBusy(project, now)
22 return (
23 <Box>
24 {isDown && !isPressed ? <Button key="start" label="Start" onPress={actions.start} /> : <Text />}
25 {isUp && !isPressed ? <Text> </Text> : <Text />}
26 {isUp && !isPressed ? <Button key="stop" label="Stop" onPress={actions.stop} /> : <Text />}
27 </Box>
28 )
29}
30
31export function drawApps($: EngineInterface, e: PaneInput, view: View) {
32 const { project, now } = view
33 const { Box, Link, Text } = $.ui.resolve(e)
34 const rows = project.apps.map(app => ({ ...app, status: statusOf(project.listening.includes(app.port), project, now) }))
35 return (
36 <Box flexDirection="column">
37 <Text bold>Apps <Text dimColor>{project.name}</Text></Text>
38 {rows.map(({ service, port, status }) => (
39 <Box flexDirection="column">
40 <Text>{service} <Text color={COLOR[status]} dimColor={status === 'stopped'}>{status}</Text></Text>
41 <Text> <Link href={`http://localhost:${port}`} /></Text>
42 </Box>
43 ))}
44 {rows.some(row => row.status === 'failed') ? <Text color="red">Didn't start in 90s. Log: {LOG_FOLDER}/{project.name}/</Text> : <Text />}
45 {controls($, e, view)}
46 </Box>
47 )
48}
49
50export function splitPane($: EngineInterface, e: PaneInput, { top, apps }: { top: RenderChildren; apps: RenderChildren }) {
51 const { Box, Text } = $.ui.resolve(e)
52 const rows = e.props.scroll.bodyRows
53 const half = Math.floor(rows / 2)
54 return (
55 <Box flexDirection="column" height={rows}>
56 <Box flexDirection="column" height={half} overflow="hidden">{top}</Box>
57 <Box flexDirection="column" height={rows - half}>
58 <Text dimColor>{'─'.repeat(e.props.bodyColumns)}</Text>
59 {apps}
60 </Box>
61 </Box>
62 )
63}
64hooks/project.ts 8 lines1const lastPart = (path: string) => path.replace(/\/+$/, '').split('/').pop() ?? ''
2
3export function projectName(commonDir: string | undefined, cwd: string): string {
4 const dir = commonDir?.trim()
5 if (!dir) return lastPart(cwd)
6 return lastPart(dir.endsWith('/.git') ? dir.slice(0, -'/.git'.length) : dir)
7}
8hooks/registry.ts 13 lines1import type { Entry } from '../types'
2
3const BROWSER = /^(web|backoffice|admin|storybook.*|docs.*)$/
4
5export function browserApps(tsv: string, project: string): Entry[] {
6 return tsv
7 .split('\n')
8 .filter(line => line.trim() && !line.startsWith('#'))
9 .map(line => line.trim().split('\t'))
10 .filter(([, name, service = '']) => name === project && BROWSER.test(service))
11 .map(([port, , service = '']) => ({ service, port: Number(port) }))
12}
13hooks/running.ts 26 lines1export const START_MS = 90_000
2export const STOP_MS = 90_000
3
4export type Status = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'
5
6export type Pending = { startedAt?: number; stoppingAt?: number }
7
8export function listeningPorts(ss: string): number[] {
9 const ports = ss
10 .split('\n')
11 .map(line => line.trim().split(/\s+/)[3])
12 .map(local => Number(local?.slice(local.lastIndexOf(':') + 1)))
13 .filter(port => port > 0)
14 return [...new Set(ports)]
15}
16
17// True while a press is within its window: a start or stop that outlived it no longer blocks the buttons.
18export const isBusy = ({ startedAt, stoppingAt }: Pending, now: number) =>
19 (startedAt !== undefined && now - startedAt <= START_MS) || (stoppingAt !== undefined && now - stoppingAt <= STOP_MS)
20
21export function statusOf(isListening: boolean, { startedAt, stoppingAt }: Pending, now: number): Status {
22 if (isListening) return stoppingAt !== undefined && now - stoppingAt <= STOP_MS ? 'stopping' : 'running'
23 if (startedAt === undefined) return 'stopped'
24 return now - startedAt <= START_MS ? 'starting' : 'failed'
25}
26types/index.d.ts 20 lines1export type Entry = { service: string; port: number }
2
3export type Launcher = { kind: 'docker'; watch: boolean } | { kind: 'node'; manager: string; scripts: string[] }
4
5export type Project = {
6 name: string
7 root: string
8 apps: Entry[]
9 launcher?: Launcher
10 listening: number[]
11 startedAt?: number
12 stoppingAt?: number
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'dev-apps': { project: Project | null }
18 }
19}
20