Launch your servers without leaving the conversation and catch the ones still running elsewhere: a count in the status line, and /quiet-server opens a pane to…

<img src="https://github.com/arthurglaizal/quiet-server/releases/download/v2.0.0/quiet-server.gif" alt="Quiet Server demo: the count in the status line, then the pane that launches this branch's servers and lists what runs elsewhere" width="100%">
Launch your servers without leaving the conversation and catch the ones still running elsewhere.
A Claude Code mod in the spirit of Port Menu, tied to the conversation instead of the menu bar: it knows which project and branch Claude is working in, what that project can launch, and what is already running.
It stays out of the way: a count in the status line, and the details in a pane when you ask.
Status line: the servers of this branch that run, out of all it runs or can launch, then what runs elsewhere on the machine. On the desktop, a ⊙ button beside it opens or closes the pane.
quiet-server 0/3 here 1 elsewhere
/quiet-server opens or closes a pane in two sections, one under the other.
here (hover it for what it means): its servers that run, then what it can launch: the package.json scripts that serve (dev, start, serve, preview, storybook…, but not the ones that only control a server, like dev:stop or dev:status) and the entries of .claude/launch.json, the file the desktop app's previews already read.main first, with an anchor, then every other worktree of the repository, each with what runs and what it can launch. This branch, main and any branch where a server runs are open; the others stay folded, their head saying what they hold (1 running · 3 to launch). A fold you press is kept.main without one shows the same button.migration-framer-to-vibecode · ~/…/2026 Migration Framer Vibecode), then a line per branch it runs from: two worktrees of one repository are one project.Each server takes two lines: its name and what it is (dev Next.js app), then what started it, how long it has been running, memory and CPU. What started it: this pane, Claude, this conversation, Claude, another conversation, the terminal app (iTerm, Terminal, VS Code…), or detached when what launched it is gone. A running server shows a small wave drifting slowly in a ring; one ready to launch, an empty dot. Its port sits on the right, in a thin outline while something listens on it.
Everything else is grey. Orange only marks something to look at: a server forgotten for a day, or a port another server already holds (two branches running dev often want the same port).
claude plugin marketplace add arthurglaizal/quiet-server
claude plugin install quiet-server@quiet-server
Start a new session and the count appears in the status line. To update later: claude plugin update quiet-server@quiet-server. To try it without installing, clone the repository and run claude --plugin-dir ./quiet-server.
| Button | Desktop | Terminal | What it does |
|---|---|---|---|
| Launch | filled play | ▶ | Runs the script through your login shell, so it finds node and npm as your terminal does |
| Copy address | two sheets | (desktop only) | Copies http://localhost:<port> |
| Open in browser | arrow | ↗ | Opens it in your default browser |
| Stop | filled square | ■ | Stops the server and everything it started (npm run dev and its children), forcing it after 3 s |
| Port | :3000 | :3000 | On the desktop, a label (Copy address copies it); in the terminal, copies the URL |
| Launch all | in the project's title line | same | Launches this branch's dev servers at once (not start, serve or preview, which serve a build); shown when there are two or more |
| Stop all | in each section's title line | same | Stops every server of the section; press twice to confirm; shown when two or more run |
| Fold | a branch's head | ▾ / ▸ | Opens or folds that branch's group |
| Launch | on another branch's head | ▶ launch | Launches that branch's dev servers, from its own worktree |
| Create worktree | on a branch with no worktree | create worktree | Puts a request in the prompt box: ask Claude to create a worktree for that branch in .claude/worktrees/ |
| Add | plus, on the project's title line | add | Puts a request in the prompt box: ask Claude to add a server to .claude/launch.json |
| Find | magnifier, next to it | find | Puts a request in the prompt box: ask Claude to look for servers the project could list |
| Count | a window with its bottom strip, last on the project's title line | hide count / show count | Shows or hides the count in the status line, and the pane's button beside it |
A server launched from the pane lives as long as the Claude session: closing the session stops it. Servers started anywhere else (a terminal, Claude itself) are listed too, and can be stopped from here.
| Command | What it does |
|---|---|
/quiet-server | Opens or closes the pane |
/quiet-server status | Hides or shows the count in the status line, as the pane's Count button does. The choice is kept across sessions |
/quiet-server demo | Shows made-up servers (a project with worktrees, a busy machine) to see the layout; again to come back |
lsof and ps.lsof for the processes listening on TCP and their folder, ps for uptime, memory and CPU, and git for each folder's repository, worktree and branch. Every 30 seconds, git worktree list, git for-each-ref and git symbolic-ref for the repository's other worktrees, its branches and its default branch, and each worktree's package.json and .claude/launch.json. It reads branches, it never switches one.$SHELL -lc) to run the script you launch, as written in package.json or .claude/launch.json, so it finds node and npm as your terminal does; kill to stop a server and what it started; open to open its address in your browser.command.run, only for /quiet-server: opens or closes the pane and answers the command itself. It never sees or changes any other command, its own or another plugin's, and never touches a permission.ui.render: draws the pane, and the ⊙ button beside the status line on the desktop.session.start: registers /quiet-server, sets the status line and starts reading the ports.quiet-server/
├── README.md
├── LICENSE
├── .claude-plugin/
│ ├── plugin.json
│ └── marketplace.json
├── hooks/
│ ├── hooks.json
│ └── register.tsx
├── types/index.d.ts
└── tests/quiet-server.test.tsx
Run the checks with claude plugin validate . and claude plugin test ..
<img src="https://github.com/arthurglaizal/quiet-model/releases/download/v2.0.0/quiet-mods.png" alt="Quiet Token Bar and Quiet Model stacked above the Claude Code prompt" width="100%">
The Quiet mods share one design: grey by default, color only when something needs you.
| Mod | Description |
|---|---|
| Quiet Token Bar | Your context window in one quiet line: a patterned bar, a compact button and your session and weekly limits, grey until something gets warm. |
| Quiet Model | Pick the model and effort in one quiet grey row. Or let Magic Router choose for each message. |
| Quiet Cost | What each reply cost, in one quiet grey line under it: an estimated price, the time and the tokens, and more figures you pick. |
If you find my work useful, you can buy me a coffee ☕️
MIT — see LICENSE.
hooks/register.tsx 1659 lines1// Quiet Server: the dev servers of this project, the ones that run and the ones ready to launch, and what runs elsewhere.
2// Port Menu, tied to the conversation. The status line keeps the count: this branch's servers that run out of all
3// it runs or can launch, and what runs elsewhere. The pane, which /quiet-server opens, has two sections, one under
4// the other. This project: what runs on the conversation's branch and what
5// it can launch (package.json scripts, .claude/launch.json), then the same project's other branches, one group each:
6// main open, the others folded under a chevron, those without a worktree on one line. Running elsewhere: every other dev server. Everything stays grey; a wave moves in what runs;
7// orange marks a server forgotten for a day, or a port another server already holds.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { Launchable, Origin, Project, Section, Server, Snapshot, Worktree } from '../types'
12
13const PANE = 'servers'
14const TICK = 5000 // how often the ports are read again while the bar or the pane shows
15const IDLE_TICKS = 3 // and, with neither, every third tick: the count alone can wait
16const FORGOTTEN = 24 * 3_600_000 // a server running longer than this is flagged
17const GIT_TTL = 30_000 // a folder's repository and branch are read again after this
18const WAVE_MS = 5000 // one wavelength of a running server's wave
19const ORANGE = '#e08a3c'
20// Absolute paths: the app's own PATH may not have the person's shell setup.
21const BIN = { lsof: '/usr/sbin/lsof', ps: '/bin/ps', git: '/usr/bin/git', kill: '/bin/kill', open: '/usr/bin/open' } as const
22const IGNORED = /^(claude|codex)$/i // assistants listening for their own reasons, not dev servers
23// Never a server, never climbed through when stopping one: apps, the program in an app's MacOS folder (Claude's own
24// binary among them), and assistants. Python.app is no app: it is how macOS's own python3 runs, from Xcode's folder.
25const FOREIGN = /(?<!Python)\.app\/Contents\/MacOS\/|(^|\/)(claude|codex)(\s|$)|@anthropic-ai\/claude-code|@openai\/codex/
26const GLYPH = { running: '◉', ready: '○', starting: '◌', stopping: '◉', copy: '⧉', open: '↗', stop: '■', start: '▶' } as const
27const TOGGLE_GLYPH = '⊙' // the footer's button: an eye seen from the front (the eye character is drawn as a color emoji)
28// What the two "ask Claude" buttons put in the prompt box, for the person to complete or send.
29const ASK = {
30 add: 'Add a dev server to this project\'s .claude/launch.json (name, runtimeExecutable, runtimeArgs, port): ',
31 find: 'Look through this project (package.json scripts, docker-compose, Makefile, READMEs, subfolders) for dev servers that .claude/launch.json does not list yet, and add the useful ones to it.',
32} as const
33const EMPTY = ['Nothing to run here yet.', 'Dev scripts from package.json and .claude/launch.json show up here.']
34const HERE_TIP = 'This conversation works on this branch'
35const MAX_TREES = 20 // the other worktrees read, at most
36const LOOSE_MAX = 12 // the branches without a worktree listed, at most
37
38// Held by the host, so the pane survives a hot reload of this file.
39const snapshot = atom({ plugin: 'quiet-server', key: 'snapshot' } as const, null as Snapshot | null)
40const isStatusHidden = atom({ plugin: 'quiet-server', key: 'isStatusHidden' } as const, false)
41const busy = atom({ plugin: 'quiet-server', key: 'busy' } as const, [] as string[]) // rows starting or stopping, by key
42const confirming = atom({ plugin: 'quiet-server', key: 'confirming' } as const, null as Section | null) // "Stop all" pressed once, there
43const opened = atom({ plugin: 'quiet-server', key: 'opened' } as const, {} as Record<string, boolean>) // a branch group folded or unfolded by hand, by repository and branch
44
45// The module's own: a reload starts them over, and ends the servers this session launched with their loops.
46const launched = new Map<string, AsyncGenerator<unknown, unknown>>() // by launchable id
47const gitCache = new Map<string, { at: number; info: GitInfo | null }>()
48const repoCache = new Map<string, { at: number; value: RepoBranches }>() // by the conversation's working tree
49const phases = new Map<string, number>() // each running row's place in its wave, so every wave moves in step
50let isRefreshing = false
51let shownStatus: string | undefined
52let ticks = 0
53let isDemo = false // /quiet-server demo: made-up servers in the pane and the status line, to see the layout
54
55export const register: Register = on => {
56 on('session.start', async ($, e, next) => {
57 const r = await next(e)
58 await $.command.register({ name: 'quiet-server', description: 'Open the servers pane (/quiet-server status hides or shows the count)' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
59 const statusHidden = (await $.store.get('isStatusHidden').catch(() => undefined)) === true
60 await update($, isStatusHidden, () => statusHidden)
61 const folds = await $.store.get('opened').catch(() => undefined)
62 if (folds && typeof folds === 'object') await update($, opened, () => folds as Record<string, boolean>)
63 void refresh($).catch(() => {})
64 $.clock.every(TICK, () => void tick($).catch(() => {}))
65 return r
66 })
67
68 on('command.run', { command: 'quiet-server' }, async ($, e) => {
69 const arg = e.args.trim()
70 if (arg === 'status') {
71 const hidden = await toggleStatus($)
72 return { text: hidden ? 'Server count hidden. /quiet-server status shows it again' : 'Server count back in the status line' }
73 }
74 if (arg === 'demo') {
75 isDemo = !isDemo
76 if (isDemo) await update($, snapshot, () => demoSnapshot())
77 else void refresh($).catch(() => {})
78 await showStatus($)
79 return { text: isDemo ? 'Demo servers shown; /quiet-server demo brings the real ones back' : 'Real servers back' }
80 }
81 return { text: await togglePane($) }
82 })
83
84 // In the prompt's footer, beside the mode labels: a button that opens or closes the pane (the status line, being
85 // text, cannot be pressed). The desktop draws its native button there and no drawing inside it: the button holds
86 // one glyph, a window with its side panel. The desktop only: the terminal has /quiet-server.
87 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
88 const rest = await next(e)
89 if (e.surface === 'terminal' || (await read($, isStatusHidden))) return rest // the pane's switch hides it with the count
90 const { Box, Button } = $.ui.resolve(e)
91 return (
92 <Box flexDirection="row" alignItems="center">
93 <Button key="pane-toggle" plain dimColor onPress={() => void togglePane($).catch(() => {})}>
94 {TOGGLE_GLYPH}
95 </Button>
96 {rest && <Box marginLeft={2}>{rest}</Box>}
97 </Box>
98 )
99 })
100
101 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
102 const { Box, Text, Button } = $.ui.resolve(e)
103 const s = await read($, snapshot)
104 if (!s) return <Text dimColor>Reading the ports…</Text>
105 const v = sections(s, await read($, busy))
106 const bulk = bulkActions($, v, await read($, confirming))
107 const inner = Math.max(30, e.props.bodyColumns - 2) // no border: the padding takes 2 cells
108 const surface = e.surface
109 const actions = (row: Row) => rowActions($, s, row, surface)
110 const copyPort = (row: Row) => () => void copy($, `http://localhost:${row.port}`, surface)
111 // The branches, each a group under a chevron: this conversation's and main open, one where something runs too;
112 // the others folded. A fold pressed by hand is kept, for this repository.
113 const folds = await read($, opened)
114 const foldKey = (id: string) => `${s.project.repo}|${id}`
115 const isOpenAt = (id: string, fallback: boolean) => folds[foldKey(id)] ?? fallback
116 const fold = (id: string, isOpen: boolean) => () => void toggleOpen($, foldKey(id), isOpen).catch(() => {})
117 const launchList = (list: Launchable[]) => (isDemo ? demoPress($) : () => void startList($, list))
118 const hereOpen = !s.project.branch || isOpenAt('@here', true)
119 const hereHead: Head | undefined = s.project.branch ? { key: 'here', name: s.project.branch, icon: s.project.branch === s.trunk ? 'anchor' : 'branch', isOpen: hereOpen, isHere: true, toggle: fold('@here', hereOpen) } : undefined
120 const groupOpen = (g: BranchGroup) => isOpenAt(g.id, g.isTrunk || g.rows.some(r => r.server !== undefined))
121 const headOf = (g: BranchGroup): Head => {
122 const isOpen = groupOpen(g)
123 const batch = g.top ? launchBatch(g.rows) : []
124 return { key: g.key, name: g.branch, icon: g.isDetached ? 'commit' : g.isTrunk ? 'anchor' : 'branch', isOpen, summary: summaryOf(g), isSpaced: true, launch: batch.length ? { names: batch.map(l => l.name), press: launchList(batch) } : undefined, toggle: fold(g.id, isOpen) }
125 }
126 const looseOpen = isOpenAt('@loose', false)
127 const looseHead: Head = { key: 'loose', name: `${v.loose.length} more branch${v.loose.length === 1 ? '' : 'es'}`, icon: 'branch', isOpen: looseOpen, summary: 'no worktree', isSpaced: true, toggle: fold('@loose', looseOpen) }
128 const makeTree = (branch: string) => (isDemo ? demoPress($) : () => void askWorktree($, branch))
129 // The two that ask Claude: add a server, or look for ones to add. Both fill the prompt; the person sends it.
130 const asks = [
131 { key: 'ask-add', icon: 'add' as const, tip: 'Ask Claude to add a server', label: 'add', press: () => void ask($, 'add') },
132 { key: 'ask-find', icon: 'find' as const, tip: 'Ask Claude to look for servers to add', label: 'find', press: () => void ask($, 'find') },
133 ]
134 // The switch for the count in the status line, after the asks: a window with its strip along the bottom, filled
135 // while the count shows there.
136 const isCountShown = !(await read($, isStatusHidden))
137 const countSwitch = { key: 'pane-status', tip: isCountShown ? 'Hide the count and button under the prompt' : 'Show the count and button under the prompt', label: isCountShown ? 'hide count' : 'show count', press: () => void toggleStatus($).catch(() => {}) }
138 const elsewhereCount = v.elsewhere.length ? String(v.elsewhere.length) : ''
139
140 // The surfaces with a vector element draw small text as SVG; the terminal draws the same rows in characters.
141 const Svg = (e.surface === 'terminal' ? undefined : ($.ui.resolve(e) as any).Svg) as ((props: { source: string; alt: string; width?: number; height?: number }) => any) | undefined
142 if (Svg) {
143 const drawn = Math.round(inner * PX_PER_COLUMN)
144 const textWidth = Math.max(160, drawn - IND_W - Math.round((TAG_CELLS + BUTTON_CELLS) * PX_PER_COLUMN) - 16)
145 keepPhases(v, await $.clock.now())
146 // A tip under its button, ending at the button's right edge, on a card of its own as quiet-model's pane draws
147 // them (the app draws none in a pane). The tree is turned over at the end so a tip covers the rows below.
148 const tip = (text: string, side: 'left' | 'right' = 'right') => (
149 <Box position="absolute" top={1} {...(side === 'left' ? { left: 0 } : { right: 0 })} width={text.length + 4} display="none" hover={{ display: 'flex' }} backgroundColor={TIP.card} borderStyle="round" borderColor={TIP.border} paddingX={1}>
150 <Text color={TIP.name}>{text}</Text>
151 </Box>
152 )
153 // A row's button: a filled icon on a faint ground, an empty button over the whole box, and a short tooltip.
154 const iconButton = (b: { key: string; svg: string; tip: string; press: () => void }) => (
155 <Box key={`${b.key}-box`} width={BUTTON_BOX} height={1} marginLeft={1} justifyContent="center" alignItems="center">
156 <Svg source={b.svg} alt={b.tip} width={BTN_W} height={BTN_H} />
157 <Box position="absolute" top={0} left={0} width={BUTTON_BOX} height={1} justifyContent="center">
158 <Button key={b.key} plain onPress={b.press}>
159 {press(BUTTON_BOX)}
160 </Button>
161 </Box>
162 {tip(b.tip)}
163 </Box>
164 )
165 // A section's quick action ("Launch all", "Stop all"): drawn small, with an empty button over it.
166 const wordButton = (b: Bulk) => {
167 const width = Math.ceil((b.label.length * SMALL_WIDTH + 19) / PX_PER_COLUMN)
168 return (
169 <Box key={`${b.key}-box`} width={width} height={1} marginLeft={2}>
170 <Svg source={wordSvg(b.label, width * PX_PER_COLUMN, b.glyph, b.isWarm)} alt={b.label} />
171 <Box position="absolute" top={0} left={0} width={width} height={1}>
172 <Button key={b.key} plain onPress={b.press}>
173 {press(width)}
174 </Button>
175 </Box>
176 {tip(b.tip)}
177 </Box>
178 )
179 }
180 // The title line's small stroked buttons: a stroked ICON px icon in a box of two cells, an empty button over it.
181 const smallButton = (b: { key: string; svg: string; tip: string; press: () => void }) => (
182 <Box key={`${b.key}-box`} width={2} height={1} marginLeft={2} justifyContent="center" alignItems="center">
183 <Svg source={b.svg} alt={b.tip} width={ICON} height={ICON} />
184 <Box position="absolute" top={0} left={0} width={2} height={1} justifyContent="center">
185 <Button key={b.key} plain onPress={b.press}>
186 {press(2)}
187 </Button>
188 </Box>
189 {tip(b.tip)}
190 </Box>
191 )
192 // After "This project": the two asks.
193 const extras = [...asks.map(a => smallButton({ key: a.key, svg: paneIcon(a.icon), tip: a.tip, press: a.press })), smallButton({ ...countSwitch, svg: statusIcon(isCountShown) })]
194 const header = (title: string, detail: string, list: Bulk[], extra: any[] = []) => (
195 <Box flexDirection="row" alignItems="center">
196 <Box flexShrink={1}>
197 <Svg source={sectionSvg(title, detail, Math.max(160, drawn - list.length * 110 - extra.length * 34))} alt={`${title}, ${detail}`} />
198 </Box>
199 <Box flexGrow={1} />
200 {list.map(wordButton)}
201 {extra}
202 </Box>
203 )
204 // A server on two lines. The state is a drawing of its own that stays the same between reads, so its wave
205 // keeps moving; the port is a label beside its buttons.
206 const rowView = (row: Row) => {
207 return (
208 <Box key={row.key} flexDirection="row" alignItems="center">
209 <Svg source={indicatorSvg(row.state, phases.get(row.key))} alt={row.state} width={IND_W} height={ROW_H} />
210 <Box flexShrink={1}>
211 <Svg source={rowSvg(row, textWidth)} alt={rowAlt(row)} />
212 </Box>
213 <Box flexGrow={1} />
214 {/* The port, a label: the row's own button copies the address. */}
215 <Box width={TAG_CELLS} height={1} flexDirection="row" justifyContent="flex-end" alignItems="center">
216 {row.port !== undefined && <Svg source={portSvg(row)} alt={`port ${row.port}`} />}
217 </Box>
218 <Box width={BUTTON_CELLS} flexDirection="row" justifyContent="flex-end">
219 {actions(row).map(a => iconButton({ key: `${row.key}:${a.kind}`, svg: buttonSvg(a.kind), tip: a.tip, press: a.press }))}
220 </Box>
221 </Box>
222 )
223 }
224 // A branch's head: the chevron, its glyph, its name, then "here" or what it holds. The whole head folds the
225 // group; "here" explains itself on hover; a group with servers to launch has its own Launch.
226 const branchHeader = (br: Head) => {
227 const drawing = branchHeadSvg({ ...br, isSpaced: false }, drawn - (br.launch ? 100 : 0))
228 const cells = Math.ceil(drawing.width / PX_PER_COLUMN)
229 // The room above a group is a drawing of its own: the empty button over the head then covers the name alone.
230 return (
231 <Box key={`${br.key}-head`} flexDirection="column">
232 {br.isSpaced && <Svg source={spaceSvg(8)} alt="" />}
233 <Box flexDirection="row" alignItems="center">
234 <Box flexShrink={1}>
235 <Svg source={drawing.svg} alt={[br.name, br.isHere ? 'this conversation' : br.summary, br.isOpen ? 'open' : 'folded'].filter(Boolean).join(', ')} />
236 <Box position="absolute" top={0} left={0} width={cells} height={1}>
237 <Button key={`toggle:${br.key}`} plain onPress={br.toggle}>
238 {press(cells)}
239 </Button>
240 </Box>
241 {drawing.tag && (
242 <Box position="absolute" top={0} left={Math.floor(drawing.tag.x / PX_PER_COLUMN)} width={Math.ceil(drawing.tag.width / PX_PER_COLUMN)} height={1}>
243 {tip(HERE_TIP, 'left')}
244 </Box>
245 )}
246 </Box>
247 <Box flexGrow={1} />
248 {br.launch && wordButton({ key: `group-launch:${br.key}`, label: 'Launch', tip: `Launch ${br.launch.names.join(', ')}`, glyph: 'start', press: br.launch.press })}
249 </Box>
250 </Box>
251 )
252 }
253 const worktreeButton = (branch: string) => wordButton({ key: `worktree:${branch}`, label: 'Create worktree', tip: `Ask Claude to create a worktree for ${branch}`, press: makeTree(branch) })
254 const note = (key: string, text: string, branch?: string) => (
255 <Box key={key} flexDirection="row" alignItems="center">
256 <Svg source={captionSvg(text, drawn - 160, false, IND_W)} alt={text} />
257 <Box flexGrow={1} />
258 {branch !== undefined && worktreeButton(branch)}
259 </Box>
260 )
261 const groupBody = (g: BranchGroup) =>
262 !g.top ? [note(`${g.key}:none`, 'No folder for this branch yet', g.branch)] : g.rows.length === 0 ? [note(`${g.key}:empty`, 'Nothing to launch on this branch')] : g.rows.map(rowView)
263 return overTheNext(
264 <Box flexDirection="column" paddingX={1}>
265 {header('This project', '', bulk.project, extras)}
266 <Svg source={projectSvg(s.project.name, drawn)} alt={s.project.name} />
267 {hereHead && branchHeader(hereHead)}
268 {hereOpen && v.here.length === 0 && <Svg source={emptySvg(EMPTY, drawn, POTTED_CACTUS)} alt={EMPTY.join(' ')} />}
269 {hereOpen && v.here.map(rowView)}
270 {v.branches.map(g => (
271 <Box key={g.key} flexDirection="column">
272 {branchHeader(headOf(g))}
273 {groupOpen(g) && groupBody(g)}
274 </Box>
275 ))}
276 {v.loose.length > 0 && (
277 <Box key="loose" flexDirection="column">
278 {branchHeader(looseHead)}
279 {looseOpen && v.loose.slice(0, LOOSE_MAX).map(b => (
280 <Box key={`loose:${b}`} flexDirection="row" alignItems="center">
281 <Svg source={captionSvg(b, textWidth, true, IND_W)} alt={b} />
282 <Box flexGrow={1} />
283 {worktreeButton(b)}
284 </Box>
285 ))}
286 {looseOpen && v.loose.length > LOOSE_MAX && note('loose:more', `and ${v.loose.length - LOOSE_MAX} more`)}
287 </Box>
288 )}
289 <Svg source={ruleSvg(drawn)} alt="Divider" />
290 {header('Running elsewhere', elsewhereCount, bulk.elsewhere)}
291 {v.elsewhere.length === 0 && <Svg source={emptySvg(['Nothing else is running.'], drawn, NIGHT)} alt="Nothing else is running." />}
292 {groups(v.elsewhere).map((g, i) => (
293 <Box key={g.key} flexDirection="column">
294 <Svg source={groupSvg(g, drawn, i > 0)} alt={[g.name, g.folder].join(', ')} />
295 {g.trees.map(t => (
296 <Box key={t.key} flexDirection="column">
297 {t.branch && <Svg source={captionSvg(t.branch, drawn, true)} alt={`branch ${t.branch}`} />}
298 {t.rows.map(rowView)}
299 </Box>
300 ))}
301 </Box>
302 ))}
303 <Box height={2} />
304 </Box>,
305 )
306 }
307
308 // The terminal: the same sections, each server on two lines.
309 const termHeader = (title: string, detail: string, list: Bulk[], withAsks = false) => (
310 <Box flexDirection="row">
311 <Text>{title}</Text>
312 <Text dimColor wrap="truncate-end">{` ${detail}`}</Text>
313 <Box flexGrow={1} />
314 {list.map(b => (
315 <Box key={`${b.key}-box`} marginLeft={2}>
316 <Button key={b.key} plain dimColor={!b.isWarm} onPress={b.press}>
317 {`${b.glyph ? `${GLYPH[b.glyph]} ` : ''}${b.label}`}
318 </Button>
319 </Box>
320 ))}
321 {withAsks &&
322 asks.map(a => (
323 <Box key={`${a.key}-box`} marginLeft={2}>
324 <Button key={a.key} plain dimColor onPress={a.press}>
325 {a.label}
326 </Button>
327 </Box>
328 ))}
329 {withAsks && (
330 <Box key={`${countSwitch.key}-box`} marginLeft={2}>
331 <Button key={countSwitch.key} plain dimColor onPress={countSwitch.press}>
332 {countSwitch.label}
333 </Button>
334 </Box>
335 )}
336 </Box>
337 )
338 const termRow = (row: Row) => {
339 const isOn = row.state === 'running' || row.state === 'stopping'
340 const live = row.state === 'running' && row.port !== undefined
341 const warm = row.note !== undefined
342 return (
343 <Box key={row.key} flexDirection="column">
344 <Box flexDirection="row">
345 <Box width={2} flexShrink={0}>
346 <Text dimColor={!isOn}>
347 {GLYPH[row.state]}
348 </Text>
349 </Box>
350 <Box flexShrink={1}>
351 <Text wrap="truncate-end">
352 <Text dimColor={!isOn}>{row.name}</Text>
353 <Text dimColor>{row.description ? ` ${row.description}` : ''}</Text>
354 </Text>
355 </Box>
356 <Box flexGrow={1} />
357 <Box width={TAG_CELLS} flexShrink={0} flexDirection="row" justifyContent="flex-end">
358 {live ? (
359 <Button key={`${row.key}:port`} plain onPress={copyPort(row)}>
360 {`:${row.port}`}
361 </Button>
362 ) : (
363 <Text color={warm ? ORANGE : undefined} dimColor={!warm} bold={warm}>
364 {row.port === undefined ? '' : `:${row.port}`}
365 </Text>
366 )}
367 </Box>
368 <Box width={5} flexShrink={0} flexDirection="row" justifyContent="flex-end">
369 {actions(row).map(a => (
370 <Box key={`${row.key}:${a.kind}-box`} marginLeft={1}>
371 <Button key={`${row.key}:${a.kind}`} plain dimColor onPress={a.press}>
372 {GLYPH[a.kind]}
373 </Button>
374 </Box>
375 ))}
376 </Box>
377 </Box>
378 <Box flexDirection="row">
379 <Box width={2} flexShrink={0} />
380 <Text wrap="truncate-end">
381 {row.meta.map((p, i) => (
382 <Text key={`${row.key}:meta:${i}`} color={p.isWarm ? ORANGE : undefined} dimColor={!p.isWarm} bold={p.isWarm}>
383 {`${i ? ' · ' : ''}${p.text}`}
384 </Text>
385 ))}
386 </Text>
387 </Box>
388 </Box>
389 )
390 }
391 // A branch's head: a chevron to press, its name, and what it holds or "this conversation".
392 const termHead = (br: Head) => (
393 <Box key={`${br.key}-head`} flexDirection="row" marginTop={br.isSpaced ? 1 : 0}>
394 <Button key={`toggle:${br.key}`} plain dimColor onPress={br.toggle}>
395 {br.isOpen ? '▾' : '▸'}
396 </Button>
397 <Box marginLeft={1} flexShrink={1}>
398 <Text dimColor={!br.isHere} wrap="truncate-end">
399 {br.isHere ? `${br.name} · this conversation` : br.name}
400 </Text>
401 </Box>
402 {!br.isHere && br.summary && <Text dimColor wrap="truncate-end">{` · ${br.summary}`}</Text>}
403 <Box flexGrow={1} />
404 {br.launch && (
405 <Box marginLeft={2}>
406 <Button key={`group-launch:${br.key}`} plain dimColor onPress={br.launch.press}>
407 {`${GLYPH.start} launch`}
408 </Button>
409 </Box>
410 )}
411 </Box>
412 )
413 const termNote = (key: string, text: string, branch?: string) => (
414 <Box key={key} flexDirection="row">
415 <Box width={2} flexShrink={0} />
416 <Text dimColor wrap="truncate-end">{text}</Text>
417 <Box flexGrow={1} />
418 {branch !== undefined && (
419 <Button key={`worktree:${branch}`} plain dimColor onPress={makeTree(branch)}>
420 create worktree
421 </Button>
422 )}
423 </Box>
424 )
425 const termBody = (g: BranchGroup) =>
426 !g.top ? [termNote(`${g.key}:none`, 'No folder for this branch yet', g.branch)] : g.rows.length === 0 ? [termNote(`${g.key}:empty`, 'Nothing to launch on this branch')] : g.rows.map(termRow)
427 return (
428 <Box flexDirection="column" paddingX={1}>
429 {termHeader('This project', '', bulk.project, true)}
430 <Text dimColor wrap="truncate-end">{s.project.name}</Text>
431 {hereHead && termHead(hereHead)}
432 {hereOpen && v.here.length === 0 && EMPTY.map(t => <Text key={`empty:${t}`} dimColor>{t}</Text>)}
433 {hereOpen && v.here.map(termRow)}
434 {v.branches.map(g => (
435 <Box key={g.key} flexDirection="column">
436 {termHead(headOf(g))}
437 {groupOpen(g) && termBody(g)}
438 </Box>
439 ))}
440 {v.loose.length > 0 && (
441 <Box key="loose" flexDirection="column">
442 {termHead(looseHead)}
443 {looseOpen && v.loose.slice(0, LOOSE_MAX).map(b => termNote(`loose:${b}`, b, b))}
444 {looseOpen && v.loose.length > LOOSE_MAX && termNote('loose:more', `and ${v.loose.length - LOOSE_MAX} more`)}
445 </Box>
446 )}
447 <Box marginTop={1} marginBottom={1}>
448 <Text dimColor>{'─'.repeat(inner)}</Text>
449 </Box>
450 {termHeader('Running elsewhere', elsewhereCount, bulk.elsewhere)}
451 {v.elsewhere.length === 0 && <Text dimColor>Nothing else is running.</Text>}
452 {groups(v.elsewhere).map((g, i) => (
453 <Box key={g.key} flexDirection="column" marginTop={i > 0 ? 1 : 0}>
454 <Text wrap="truncate-end">
455 <Text>{g.name}</Text>
456 <Text dimColor>{` ${g.folder}`}</Text>
457 </Text>
458 {g.trees.map(t => (
459 <Box key={t.key} flexDirection="column">
460 {t.branch && <Text dimColor wrap="truncate-end">{`⑂ ${t.branch}`}</Text>}
461 {t.rows.map(termRow)}
462 </Box>
463 ))}
464 </Box>
465 ))}
466 </Box>
467 )
468 })
469}
470
471// A note on hover is painted over what comes before it in the tree, not over what comes after: a row's tip would
472// sit under the rows below. Every column turned upside down in the tree, drawn the right way up with
473// `column-reverse`, puts each row after the rows below it, so its tip covers them.
474export function overTheNext(node: any): any {
475 if (!node || typeof node !== 'object') return node
476 const children = Array.isArray(node.children) ? node.children.flat(Infinity).map(overTheNext) : node.children
477 if (node.type === 'Box' && node.props?.flexDirection === 'column' && Array.isArray(children)) {
478 return { ...node, props: { ...node.props, flexDirection: 'column-reverse' }, children: [...children].reverse() }
479 }
480 return children === node.children ? node : { ...node, children }
481}
482
483// ——— What a row and a section offer ———
484
485type Action = { kind: 'copy' | 'open' | 'stop' | 'start'; tip: string; press: () => void }
486
487// A running server: copy its address, open it in the default browser, or stop it.
488function rowActions($: EngineInterface, s: Snapshot, row: Row, surface: string): Action[] {
489 return isDemo ? realActions($, s, row, surface).map(a => ({ ...a, press: demoPress($) })) : realActions($, s, row, surface)
490}
491
492const demoPress = ($: EngineInterface) => () => void $.ui.toast('Demo servers: nothing to act on. /quiet-server demo brings the real ones back')
493
494function realActions($: EngineInterface, s: Snapshot, row: Row, surface: string): Action[] {
495 if (row.state === 'starting' || row.state === 'stopping') return []
496 if (row.state === 'ready') {
497 if (!row.launchable) return []
498 const l = row.launchable
499 return [{ kind: 'start', tip: 'Launch', press: () => void start($, l) }]
500 }
501 const url = row.port ? `http://localhost:${row.port}` : undefined
502 const out: Action[] = []
503 if (url && surface === 'desktop') out.push({ kind: 'copy', tip: 'Copy address', press: () => void copy($, url, surface) }) // the terminal's port copies it
504 if (url) out.push({ kind: 'open', tip: 'Open in browser', press: () => void open($, url) })
505 out.push({ kind: 'stop', tip: 'Stop', press: () => void stop($, s, row.server!) })
506 return out
507}
508
509type Bulk = { key: string; label: string; tip: string; glyph?: 'start' | 'stop'; isWarm?: boolean; press: () => void }
510
511// A branch group's head, as both surfaces draw it.
512export type Head = {
513 key: string
514 name: string
515 icon: 'branch' | 'anchor' | 'commit' // a branch; main, the anchor; a worktree on no branch
516 isOpen: boolean
517 isHere?: boolean // the conversation's branch: tagged "here"
518 summary?: string // "1 running · 3 to launch"
519 isSpaced?: boolean
520 launch?: { names: string[]; press: () => void }
521 toggle: () => void
522}
523
524// "Launch all" over this branch's dev servers, "Stop all" over a section's servers: only when there are two or more.
525function bulkActions($: EngineInterface, v: Sections, asking: Section | null): Record<Section, Bulk[]> {
526 const batch = launchBatch(v.here)
527 const stopAll = (section: Section, rows: Row[]): Bulk[] => {
528 const n = rows.filter(r => r.state === 'running').length
529 if (n < 2) return []
530 const isAsking = asking === section
531 const tip = isAsking ? 'Press again to stop them' : `Stop the ${n} servers ${section === 'project' ? 'of this project' : 'running elsewhere'}`
532 return [{ key: `stop-${section}`, label: isAsking ? `Confirm stop (${n})` : 'Stop all', tip, glyph: 'stop', isWarm: isAsking, press: () => void pressStopAll($, section) }]
533 }
534 const launchAll: Bulk[] = batch.length < 2 ? [] : [{ key: 'launch-all', label: 'Launch all', tip: `Launch ${batch.map(l => l.name).join(', ')}`, glyph: 'start', press: () => void startAll($) }]
535 const out = { project: [...launchAll, ...stopAll('project', [...v.here, ...v.other])], elsewhere: stopAll('elsewhere', v.elsewhere) }
536 if (isDemo) for (const list of Object.values(out)) for (const b of list) b.press = demoPress($)
537 return out
538}
539
540// Made-up servers, as a project with worktrees and a busy machine would show: this branch running one of three,
541// launched from the pane, a worktree on main running one of two, another on a fix with two to launch, two branches
542// with no worktree, and four elsewhere in three projects, started from a terminal,
543// another conversation, or left detached and forgotten for days.
544export function demoSnapshot(): Snapshot {
545 const home = '/Users/me/code'
546 const top = `${home}/atelier-web`
547 const repo = `${top}/.git`
548 const launchableAt = (dir: string, name: string, command: string, port: number): Launchable => ({ id: `${dir}#${name}`, name, script: name, command, run: `npm run ${name}`, cwd: dir, source: 'package.json', port, description: describe(command) })
549 const launchable = (name: string, command: string, port: number) => launchableAt(top, name, command, port)
550 const server = (pid: number, name: string, command: string, ports: number[], cwd: string, git: { top: string; repo: string; name: string; branch: string } | null, minutes: number, mb: number, cpu: number, origin: Origin): Server => ({
551 pid,
552 pids: [pid],
553 name,
554 description: describe(command),
555 ports,
556 cwd,
557 top: git?.top ?? cwd,
558 repo: git?.repo ?? cwd,
559 projectName: git?.name ?? basename(cwd),
560 branch: git?.branch ?? '',
561 uptimeMs: minutes * 60_000,
562 rssKb: mb * 1024,
563 cpu,
564 origin,
565 })
566 const mainTree = `${home}/atelier-web-main`
567 const cartTree = `${home}/atelier-web-cart`
568 return {
569 project: { cwd: top, top, repo, name: 'atelier-web', branch: 'feat/checkout' },
570 launchables: [launchable('dev', 'next dev -p 3001', 3001), launchable('storybook', 'storybook dev -p 6006', 6006), launchable('preview', 'next start -p 4173', 4173)],
571 worktrees: [
572 { top: mainTree, branch: 'main', isDetached: false, launchables: [launchableAt(mainTree, 'dev', 'next dev', 3000), launchableAt(mainTree, 'storybook', 'storybook dev -p 6008', 6008)] },
573 { top: cartTree, branch: 'fix/cart-total', isDetached: false, launchables: [launchableAt(cartTree, 'dev', 'next dev -p 3002', 3002), launchableAt(cartTree, 'storybook', 'storybook dev -p 6009', 6009)] },
574 ],
575 branches: ['design/tokens', 'feat/checkout', 'fix/cart-total', 'main', 'spike/edge-cache'],
576 trunk: 'main',
577 servers: [
578 server(90001, 'dev', 'npm run dev\nnode .bin/next dev -p 3001', [3001], top, { top, repo, name: 'atelier-web', branch: 'feat/checkout' }, 12, 310, 3, { kind: 'pane' }),
579 server(90002, 'dev', 'npm run dev\nnode .bin/next dev', [3000], mainTree, { top: mainTree, repo, name: 'atelier-web', branch: 'main' }, 130, 280, 1, { kind: 'terminal', app: 'iTerm' }),
580 server(90003, 'vite', 'node .bin/vite', [5173], `${home}/portfolio`, { top: `${home}/portfolio`, repo: `${home}/portfolio/.git`, name: 'portfolio', branch: 'main' }, 3 * 1440 + 90, 190, 0, { kind: 'detached' }),
581 server(90004, 'uvicorn', 'uvicorn main:app --port 8000', [8000], `${home}/api-billing`, { top: `${home}/api-billing`, repo: `${home}/api-billing/.git`, name: 'api-billing', branch: 'dev' }, 40, 95, 2, { kind: 'claude', app: 'another conversation' }),
582 server(90005, 'dev', 'npm run dev\nnode .bin/astro dev', [4321], `${home}/docs`, { top: `${home}/docs`, repo: `${home}/docs/.git`, name: 'docs', branch: 'main' }, 65, 120, 0, { kind: 'terminal', app: 'Terminal' }),
583 server(90006, 'storybook', 'npm run storybook\nnode .bin/storybook dev -p 6007', [6007], `${home}/docs`, { top: `${home}/docs`, repo: `${home}/docs/.git`, name: 'docs', branch: 'main' }, 64, 240, 1, { kind: 'terminal', app: 'Terminal' }),
584 ],
585 }
586}
587
588// What "Launch all" starts: the servers one works with, not the ones that serve a build (start, serve, preview),
589// nor a second one on a port the batch already takes.
590export function launchBatch(rows: Row[]): Launchable[] {
591 const ports = new Set<number>()
592 const out: Launchable[] = []
593 for (const r of rows) {
594 const l = r.launchable
595 if (r.state !== 'ready' || !l || r.note || /^(start|serve|preview)($|:)/.test(l.script ?? l.name) || /\b(start|preview)\b/.test(l.command)) continue
596 if (l.port !== undefined && ports.has(l.port)) continue
597 if (l.port !== undefined) ports.add(l.port)
598 out.push(l)
599 }
600 return out
601}
602
603// ——— The rows, as the pane shows them ———
604
605export type Part = { text: string; isWarm?: boolean }
606
607export type Row = {
608 key: string
609 section: 'here' | 'other' | 'elsewhere'
610 state: 'running' | 'ready' | 'starting' | 'stopping'
611 name: string
612 description: string
613 branch: string
614 port?: number
615 meta: Part[] // the second line: the branch, uptime, memory and CPU; or what a launch runs
616 isForgotten: boolean
617 note?: string // orange: "used by main", the port another server holds
618 server?: Server
619 launchable?: Launchable
620}
621
622// Another branch of the project: a worktree with its rows, or main with no worktree yet (no `top`).
623export type BranchGroup = { key: string; id: string; branch: string; top?: string; isTrunk: boolean; isDetached: boolean; rows: Row[] }
624
625// `other`: every branch group's rows, for "Stop all" and the waves. `loose`: the branches with no worktree.
626export type Sections = { here: Row[]; branches: BranchGroup[]; loose: string[]; other: Row[]; elsewhere: Row[] }
627
628// This branch: what runs, then what it can launch. Other branches: the same project's other worktrees, main first,
629// each with what runs and what it can launch, then the branches with no worktree. Elsewhere: every other server,
630// by project and port.
631export function sections(s: Snapshot, pending: string[] = []): Sections {
632 const { project, launchables, servers } = s
633 const byPort = (a: Server, b: Server) => (a.ports[0] ?? 0) - (b.ports[0] ?? 0)
634 // Without git, the tree is the folder itself: a server run from a subfolder of it is still this project's.
635 const hasGit = (x: { top: string; repo: string }) => x.repo !== x.top
636 const isMine = (x: Server) => x.top === project.top || (!hasGit(x) && !hasGit(project) && (x.cwd === project.top || x.cwd.startsWith(`${project.top}/`)))
637 const here = treeRows(launchables, servers.filter(isMine).sort(byPort), project, servers, pending, 'here')
638 // The other worktrees git lists, and any a server runs from that it did not.
639 const theirs = servers.filter(x => x.repo === project.repo && !isMine(x)).sort(byPort)
640 const trees = new Map<string, Worktree>()
641 for (const w of s.worktrees ?? []) if (w.top !== project.top) trees.set(w.top, w)
642 for (const sv of theirs) if (!trees.has(sv.top)) trees.set(sv.top, { top: sv.top, branch: sv.branch, isDetached: sv.branch === 'detached', launchables: [] })
643 const trunk = s.trunk ?? ''
644 const branches: BranchGroup[] = [...trees.values()].map(w => ({
645 key: `branch:${w.top}`,
646 id: w.isDetached ? w.top : w.branch,
647 branch: w.isDetached ? basename(w.top) : w.branch,
648 top: w.top,
649 isTrunk: !w.isDetached && w.branch === trunk && trunk !== '',
650 isDetached: w.isDetached,
651 rows: treeRows(w.launchables, theirs.filter(x => x.top === w.top), w, servers, pending, 'other'),
652 }))
653 const checkedOut = new Set([project.branch, ...[...trees.values()].map(w => w.branch)])
654 if (trunk && !checkedOut.has(trunk)) branches.push({ key: 'branch:trunk', id: trunk, branch: trunk, isTrunk: true, isDetached: false, rows: [] })
655 branches.sort((a, b) => Number(b.isTrunk) - Number(a.isTrunk) || Number(a.isDetached) - Number(b.isDetached) || a.branch.localeCompare(b.branch))
656 const elsewhere = servers.filter(x => x.repo !== project.repo && !isMine(x)).sort((a, b) => a.projectName.localeCompare(b.projectName) || byPort(a, b))
657 return {
658 here,
659 branches,
660 loose: (s.branches ?? []).filter(b => !checkedOut.has(b) && b !== trunk),
661 other: branches.flatMap(g => g.rows),
662 elsewhere: elsewhere.map(sv => serverRow(sv, 'elsewhere', undefined, pending)),
663 }
664}
665
666// A working tree's rows: what runs from it, then what it can launch. A server claims one launchable: the one it
667// carries the name, script or port of, else the one whose command runs the same program ("next" for `next dev`).
668// Each launchable is claimed once: `next dev` leaves `start` ready.
669function treeRows(launchables: Launchable[], svs: Server[], place: { top: string; branch: string }, servers: Server[], pending: string[], section: Row['section']): Row[] {
670 const claimed = new Set<string>()
671 const claim = (sv: Server) => {
672 const l = launchables.find(l => !claimed.has(l.id) && matches(l, sv)) ?? launchables.find(l => !claimed.has(l.id) && binaryName(l.command) === sv.name)
673 if (l) claimed.add(l.id)
674 return l
675 }
676 const running = svs.map(sv => serverRow(sv, section, claim(sv), pending))
677 return [...running, ...launchables.filter(l => !claimed.has(l.id)).map(l => readyRow(l, place, servers, pending, section))]
678}
679
680// What a folded group's head says it holds: "1 running · 3 to launch", "no worktree".
681export function summaryOf(g: BranchGroup) {
682 if (!g.top) return 'no worktree'
683 const running = g.rows.filter(r => r.server).length
684 const ready = g.rows.length - running
685 const parts = [...(g.isDetached ? ['detached'] : []), ...(running ? [`${running} running`] : []), ...(ready ? [`${ready} to launch`] : [])]
686 return parts.join(' · ') || 'nothing to launch'
687}
688
689// The count, one line of text in the status line: "0/0 here", what runs on this branch against all it runs or could
690// launch, then "1 elsewhere", a wide space between the two. The app already writes "quiet-server" before it.
691export function statusText(s: Snapshot) {
692 const { here, elsewhere } = sections(s)
693 return `${here.filter(r => r.server).length}/${here.length} here\u2003${elsewhere.length} elsewhere`
694}
695
696// A launchable is running when a server of its working tree carries its name, its script or its port.
697export function matches(l: Launchable, sv: Server) {
698 return sv.name === l.name || (l.script !== undefined && sv.name === l.script) || (l.port !== undefined && sv.ports.includes(l.port))
699}
700
701// A server goes by its script ("dev", then "Next.js app"); its second line starts with what started it. Its branch,
702// or its project and branch elsewhere, head its group: the line does not repeat them.
703function serverRow(sv: Server, section: Row['section'], l: Launchable | undefined, pending: string[]): Row {
704 const key = `pid:${sv.pid}`
705 const isStopping = pending.includes(key)
706 const isForgotten = sv.uptimeMs > FORGOTTEN
707 const origin = originText(sv.origin)
708 return {
709 key,
710 section,
711 state: isStopping ? 'stopping' : 'running',
712 name: l?.name ?? sv.name,
713 description: sv.description || l?.description || '',
714 branch: sv.branch,
715 port: sv.ports[0],
716 meta: isStopping
717 ? [{ text: 'Stopping…' }]
718 : [...(origin ? [{ text: origin }] : []), { text: duration(sv.uptimeMs), isWarm: isForgotten }, { text: memory(sv.rssKb) }, { text: `${Math.round(sv.cpu)}% CPU` }],
719 isForgotten,
720 server: sv,
721 launchable: l,
722 }
723}
724
725function readyRow(l: Launchable, place: { top: string; branch: string }, servers: Server[], pending: string[], section: Row['section'] = 'here'): Row {
726 const key = `launch:${l.id}`
727 const isStarting = pending.includes(key)
728 const holder = l.port === undefined ? undefined : servers.find(sv => sv.ports.includes(l.port!))
729 const note = holder ? `used by ${holder.top === place.top ? holder.name : holder.branch || holder.projectName}` : undefined
730 return {
731 key,
732 section,
733 state: isStarting ? 'starting' : 'ready',
734 name: l.name,
735 description: l.description,
736 branch: place.branch,
737 port: l.port,
738 meta: isStarting ? [{ text: 'Starting…' }] : [...(note ? [{ text: `port ${l.port} ${note}`, isWarm: true }] : []), { text: l.run }],
739 isForgotten: false,
740 note,
741 launchable: l,
742 }
743}
744
745// Running elsewhere, by project: one group per repository, headed by its name and the folder it sits in, then a
746// line per working tree it runs from, headed by its branch, in the order the rows come (by project, then port).
747export type Tree = { key: string; branch: string; rows: Row[] }
748export type Group = { key: string; name: string; folder: string; trees: Tree[] }
749export function groups(rows: Row[]): Group[] {
750 const out = new Map<string, Group>()
751 for (const r of rows) {
752 const sv = r.server!
753 const home = sv.repo.endsWith('/.git') ? sv.repo.slice(0, -5) : sv.top // the main worktree, where the repository sits
754 const g = out.get(sv.repo) ?? { key: `group:${sv.repo}`, name: sv.projectName, folder: folderOf(home), trees: [] }
755 const t = g.trees.find(x => x.key === `tree:${sv.top}`) ?? (g.trees.push({ key: `tree:${sv.top}`, branch: sv.branch, rows: [] }), g.trees.at(-1)!)
756 t.rows.push(r)
757 out.set(sv.repo, g)
758 }
759 return [...out.values()]
760}
761
762// The folder a project sits in, from the home folder ("~/code"), its middle cut when deep ("~/…/2026 Claude mods").
763export function folderOf(top: string) {
764 const parts = top.replace(/^\/Users\/[^/]+(?=\/|$)/, '~').split('/').slice(0, -1)
765 return parts.length > 3 ? `${parts[0]}/…/${parts.at(-1)}` : parts.join('/') || '/'
766}
767
768const rowAlt = (row: Row) => [row.name, row.description, ...row.meta.map(p => p.text), row.port ? `port ${row.port}` : ''].filter(Boolean).join(', ')
769
770// Each running row keeps the offset it got when it appeared: its satellite then sits where the clock's turn is,
771// like every other one. Rows gone are forgotten.
772function keepPhases(v: Sections, now: number) {
773 const keys = new Set([...v.here, ...v.other, ...v.elsewhere].filter(r => r.state === 'running').map(r => r.key))
774 for (const key of keys) if (!phases.has(key)) phases.set(key, -((now % WAVE_MS) / 1000))
775 for (const key of phases.keys()) if (!keys.has(key)) phases.delete(key)
776}
777
778// ——— Reading the machine ———
779
780type GitInfo = { top: string; repo: string; name: string; branch: string }
781
782// Reads where the conversation works, what it can launch and what listens, then updates the pane and the count.
783async function refresh($: EngineInterface) {
784 if (isRefreshing || isDemo) return // one at a time: a slow read is not stacked on; the demo stays as it is
785 isRefreshing = true
786 try {
787 const now = await $.clock.now()
788 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
789 const project = await projectOf($, await $.session.cwd(), now)
790 const [launchables, servers, repo] = await Promise.all([findLaunchables($, project), scanServers($, home, now), repoBranches($, project, now)])
791 const fresh: Snapshot = { project, launchables, servers, ...repo }
792 await update($, snapshot, () => fresh)
793 // A launch is done once its server listens, a stop once its process is gone.
794 const v = sections(fresh)
795 const runningKeys = new Set([...v.here, ...v.other].filter(r => r.launchable && r.server).map(r => `launch:${r.launchable!.id}`))
796 const pids = new Set(servers.map(sv => `pid:${sv.pid}`))
797 await update($, busy, list => list.filter(k => (k.startsWith('launch:') ? !runningKeys.has(k) : pids.has(k))))
798 await showStatus($)
799 } finally {
800 isRefreshing = false
801 }
802}
803
804// A tick reads the machine while the pane shows, else one tick in IDLE_TICKS.
805async function tick($: EngineInterface) {
806 ticks++
807 const isShown = (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
808 if (isShown || ticks % IDLE_TICKS === 0) await refresh($)
809}
810
811// The count in the status line, unless /quiet-server status hid it.
812// /quiet-server status and the pane's switch: the choice is kept across sessions.
813async function toggleStatus($: EngineInterface) {
814 const hidden = await update($, isStatusHidden, wasHidden => !wasHidden)
815 await $.store.set('isStatusHidden', hidden).catch(() => {})
816 await showStatus($)
817 return hidden
818}
819
820async function showStatus($: EngineInterface) {
821 const s = await read($, snapshot)
822 const text = (await read($, isStatusHidden)) || !s ? undefined : statusText(s)
823 if (text === shownStatus) return
824 shownStatus = text
825 $.ui.status(text)
826}
827
828async function run($: EngineInterface, argv: string[], cwd?: string) {
829 try {
830 return await $.process.run(argv, { timeoutMs: 10_000, ...(cwd ? { cwd } : {}) })
831 } catch (error) {
832 return { exitCode: -1, stdout: '', stderr: error instanceof Error ? error.message : String(error), isStdoutTruncated: false, isStderrTruncated: false }
833 }
834}
835
836// Cached for GIT_TTL, unless `isFresh`: the conversation's own folder is read every time, so a checkout shows at once.
837async function gitInfo($: EngineInterface, dir: string, now: number, isFresh = false): Promise<GitInfo | null> {
838 const kept = gitCache.get(dir)
839 if (kept && !isFresh && now - kept.at < GIT_TTL) return kept.info
840 const where = await run($, [BIN.git, '-C', dir, 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'])
841 // symbolic-ref, not rev-parse HEAD: it also answers in a repository with no commit yet.
842 const head = where.exitCode === 0 ? await run($, [BIN.git, '-C', dir, 'symbolic-ref', '--short', '-q', 'HEAD']) : undefined
843 const info = where.exitCode === 0 ? toGitInfo(where.stdout, head?.exitCode === 0 ? head.stdout.trim() : 'detached') : null
844 gitCache.set(dir, { at: now, info })
845 return info
846}
847
848async function projectOf($: EngineInterface, cwd: string, now: number): Promise<Project> {
849 const git = await gitInfo($, cwd, now, true)
850 return git ? { cwd, ...git } : { cwd, top: cwd, repo: cwd, name: basename(cwd), branch: '' }
851}
852
853type RepoBranches = { worktrees: Worktree[]; branches: string[]; trunk: string }
854
855// The repository's other worktrees with what each can launch, its local branches, and its default branch: what
856// origin's HEAD names, else main, else master. Read again after GIT_TTL.
857async function repoBranches($: EngineInterface, p: Project, now: number): Promise<RepoBranches> {
858 if (p.repo === p.top) return { worktrees: [], branches: [], trunk: '' }
859 const kept = repoCache.get(p.top)
860 if (kept && now - kept.at < GIT_TTL) return kept.value
861 const [list, refs, head] = await Promise.all([
862 run($, [BIN.git, '-C', p.top, 'worktree', 'list', '--porcelain']),
863 run($, [BIN.git, '-C', p.top, 'for-each-ref', '--format=%(refname:short)', 'refs/heads']),
864 run($, [BIN.git, '-C', p.top, 'symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD']),
865 ])
866 const branches = refs.exitCode === 0 ? refs.stdout.split('\n').map(b => b.trim()).filter(Boolean) : []
867 const worktrees: Worktree[] = []
868 for (const w of parseWorktrees(list.exitCode === 0 ? list.stdout : '').filter(w => w.top !== p.top).slice(0, MAX_TREES)) {
869 worktrees.push({ ...w, launchables: await findLaunchables($, { cwd: w.top, top: w.top, repo: p.repo, name: p.name, branch: w.branch }) })
870 }
871 const value = { worktrees, branches, trunk: trunkOf(branches, head.exitCode === 0 ? head.stdout.trim() : '') }
872 repoCache.set(p.top, { at: now, value })
873 return value
874}
875
876async function readJson($: EngineInterface, path: string): Promise<any> {
877 try {
878 return JSON.parse(await $.fs.read(path))
879 } catch {
880 return undefined
881 }
882}
883
884// The package.json scripts that serve, in the working tree's root and in the folder the conversation is in, and
885// the entries of .claude/launch.json (the file the app's own previews read). An entry that runs a script stands for it.
886async function findLaunchables($: EngineInterface, p: Project): Promise<Launchable[]> {
887 const configs = launchConfigs(await readJson($, `${p.top}/.claude/launch.json`), p.top)
888 const scripts: Launchable[] = []
889 for (const dir of [...new Set([p.top, p.cwd])]) {
890 const pkg = await readJson($, `${dir}/package.json`)
891 if (pkg) scripts.push(...packageScripts(pkg, dir, await packageManager($, dir, p.top)))
892 }
893 const covered = new Set(configs.map(c => c.script).filter(Boolean))
894 return [...configs, ...scripts.filter(l => !covered.has(l.name))]
895}
896
897async function packageManager($: EngineInterface, dir: string, top: string) {
898 for (const base of [...new Set([dir, top])]) {
899 for (const [file, pm] of [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun']] as const) {
900 if (await $.fs.exists(`${base}/${file}`).catch(() => false)) return pm
901 }
902 }
903 return 'npm'
904}
905
906// Every process listening on TCP whose folder is one of the person's projects, grouped by what launched it.
907async function scanServers($: EngineInterface, home: string, now: number): Promise<Server[]> {
908 const listening = parseListeners((await run($, [BIN.lsof, '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpcn'])).stdout)
909 const pids = [...listening.keys()].filter(pid => !IGNORED.test(listening.get(pid)!.command))
910 if (pids.length === 0) return []
911 const [cwdOut, psOut] = await Promise.all([
912 run($, [BIN.lsof, '-a', '-d', 'cwd', '-Fn', '-p', pids.join(',')]),
913 run($, [BIN.ps, '-axo', 'pid=,ppid=,etime=,rss=,%cpu=,command=']),
914 ])
915 const cwds = parseCwds(cwdOut.stdout)
916 const procs = parsePs(psOut.stdout)
917 const self = selfClaude(procs)
918 const byRoot = new Map<number, Server>()
919 for (const pid of pids) {
920 const cwd = cwds.get(pid)
921 if (!cwd || !procs.has(pid) || !isDevFolder(cwd, home) || FOREIGN.test(procs.get(pid)!.command)) continue // apps opened from the Finder run from "/"
922 const root = launcher(pid, procs)
923 const ports = listening.get(pid)!.ports
924 const known = byRoot.get(root)
925 if (known) {
926 known.ports = [...new Set([...known.ports, ...ports])].sort((a, b) => a - b) // one server, several ports
927 continue
928 }
929 const git = await gitInfo($, cwd, now)
930 const family = [root, ...descendants(root, procs)]
931 const chain = [...ancestors(pid, procs, root), pid].map(p => procs.get(p)!.command)
932 byRoot.set(root, {
933 pid,
934 pids: family,
935 name: scriptName(chain) ?? binaryName(procs.get(pid)!.command),
936 description: describe(chain.join('\n')),
937 ports,
938 cwd,
939 top: git?.top ?? cwd,
940 repo: git?.repo ?? cwd,
941 projectName: git?.name ?? basename(cwd),
942 branch: git?.branch ?? '',
943 uptimeMs: procs.get(root)!.elapsedMs,
944 rssKb: family.reduce((n, p) => n + (procs.get(p)?.rssKb ?? 0), 0),
945 cpu: family.reduce((n, p) => n + (procs.get(p)?.cpu ?? 0), 0),
946 origin: originOf(root, procs, self),
947 })
948 }
949 return [...byRoot.values()]
950}
951
952// ——— Acting on a server ———
953
954async function mark($: EngineInterface, key: string, isBusy: boolean) {
955 await update($, busy, list => (isBusy ? [...new Set([...list, key])] : list.filter(k => k !== key)))
956}
957
958// Runs the entry through the person's login shell, so it finds node and npm as their terminal does. The server
959// lives as long as this session: closing the session, or reloading this mod, ends it.
960async function start($: EngineInterface, l: Launchable) {
961 const key = `launch:${l.id}`
962 if (launched.has(l.id) || (await read($, busy)).includes(key)) return
963 await mark($, key, true)
964 const shell = (await $.env.get('SHELL').catch(() => undefined)) || '/bin/zsh'
965 const child = $.process.spawn({ argv: [shell, '-lc', `exec ${l.run}`], cwd: l.cwd })
966 launched.set(l.id, child)
967 void (async () => {
968 try {
969 for await (const chunk of child) void chunk // the output is not kept yet
970 } catch {
971 void $.ui.toast(`Could not start ${l.name}`)
972 } finally {
973 launched.delete(l.id)
974 await mark($, key, false).catch(() => {})
975 void refresh($).catch(() => {})
976 }
977 })()
978 for (const ms of [1500, 4000, 8000]) $.clock.after(ms, () => void refresh($).catch(() => {})) // until its port opens
979 $.clock.after(20_000, () => void mark($, key, false).catch(() => {}))
980}
981
982// Asks the server and everything it runs to stop, then forces whatever is still there after 3 s.
983async function stop($: EngineInterface, s: Snapshot, sv: Server) {
984 await mark($, `pid:${sv.pid}`, true)
985 const all = [...s.launchables, ...(s.worktrees ?? []).flatMap(w => w.launchables)]
986 const own = all.find(l => (l.cwd === sv.top || l.cwd.startsWith(`${sv.top}/`)) && matches(l, sv) && launched.has(l.id))
987 if (own) void launched.get(own.id)!.return(undefined) // one this session launched: ending its loop ends it
988 const pids = sv.pids.filter(p => p > 1).map(String)
989 await run($, [BIN.kill, '-TERM', ...pids])
990 $.clock.after(800, () => void refresh($).catch(() => {}))
991 $.clock.after(3000, () => {
992 void (async () => {
993 if ((await run($, [BIN.kill, '-0', String(sv.pid)])).exitCode === 0) await run($, [BIN.kill, '-KILL', ...pids])
994 await refresh($)
995 })().catch(() => {})
996 })
997}
998
999// Puts a request in the prompt box, for the person to complete (add) or send as is (find).
1000async function ask($: EngineInterface, what: keyof typeof ASK) {
1001 const done = await $.prompt.fill({ text: ASK[what], mode: 'replace' }).catch(() => undefined)
1002 void $.ui.toast(done?.isFilled ? 'Prompt filled: complete it and send' : 'Could not fill the prompt')
1003}
1004
1005// "Create worktree": a request in the prompt box, for the person to send. Claude creates the folder; the pane lists it.
1006async function askWorktree($: EngineInterface, branch: string) {
1007 const text = `Create a git worktree for the branch ${branch} in .claude/worktrees/, and install its dependencies if it needs any, so its dev servers can run beside this one.`
1008 const done = await $.prompt.fill({ text, mode: 'replace' }).catch(() => undefined)
1009 void $.ui.toast(done?.isFilled ? 'Prompt filled: send it to create the worktree' : 'Could not fill the prompt')
1010}
1011
1012// A branch group folded or unfolded by hand: kept across sessions.
1013async function toggleOpen($: EngineInterface, key: string, wasOpen: boolean) {
1014 const folds = await update($, opened, o => ({ ...o, [key]: !wasOpen }))
1015 await $.store.set('opened', folds).catch(() => {})
1016}
1017
1018async function startList($: EngineInterface, list: Launchable[]) {
1019 for (const l of list) await start($, l)
1020}
1021
1022// Opens the pane, or closes it when it is up: the command and the bar's button.
1023async function togglePane($: EngineInterface) {
1024 const pane = (await $.ui.panes()).find(p => p.id === PANE)
1025 if (pane?.isShown) {
1026 await $.ui.close({ id: PANE })
1027 return 'Servers pane closed'
1028 }
1029 void refresh($).catch(() => {})
1030 const opened = await $.ui.open({ id: PANE, title: 'Servers' })
1031 return opened.isPlaced ? 'Servers pane opened' : `Servers pane waiting: ${opened.reason}`
1032}
1033
1034async function open($: EngineInterface, url: string) {
1035 const done = await run($, [BIN.open, url])
1036 if (done.exitCode !== 0) void $.ui.toast(`Could not open ${url}: ${done.stderr.trim().slice(0, 140) || `exit ${done.exitCode}`}`)
1037}
1038
1039async function copy($: EngineInterface, url: string, surface: string) {
1040 const done = await $.ui.copy({ text: url, surface: surface as any }).catch(() => undefined)
1041 void $.ui.toast(done?.isCopied ? `Copied ${url}` : `Could not copy ${url}`)
1042}
1043
1044async function startAll($: EngineInterface) {
1045 const s = await read($, snapshot)
1046 if (!s) return
1047 for (const l of launchBatch(sections(s, await read($, busy)).here)) await start($, l)
1048}
1049
1050// "Stop all" asks once more, for 5 s, before stopping every server of its section.
1051async function pressStopAll($: EngineInterface, section: Section) {
1052 const s = await read($, snapshot)
1053 if (!s) return
1054 if ((await read($, confirming)) !== section) {
1055 await update($, confirming, () => section)
1056 $.clock.after(5000, () => void update($, confirming, c => (c === section ? null : c)).catch(() => {}))
1057 return
1058 }
1059 await update($, confirming, () => null)
1060 const v = sections(s, await read($, busy))
1061 for (const r of section === 'project' ? [...v.here, ...v.other] : v.elsewhere) if (r.state === 'running') await stop($, s, r.server!)
1062}
1063
1064// ——— Reading command output (pure, tested) ———
1065
1066export type Proc = { pid: number; ppid: number; elapsedMs: number; rssKb: number; cpu: number; command: string }
1067
1068// `git worktree list --porcelain`: a block per worktree, its path, then its branch or "detached". A bare repository
1069// and a worktree whose folder is gone ("prunable") are left out.
1070export function parseWorktrees(out: string): Omit<Worktree, 'launchables'>[] {
1071 const list: Omit<Worktree, 'launchables'>[] = []
1072 for (const block of out.split(/\n\s*\n/)) {
1073 const lines = block.split('\n')
1074 const top = lines.find(l => l.startsWith('worktree '))?.slice(9)
1075 if (!top || lines.some(l => l === 'bare' || l.startsWith('prunable'))) continue
1076 const ref = lines.find(l => l.startsWith('branch '))?.slice(7)
1077 list.push({ top, branch: ref ? ref.replace(/^refs\/heads\//, '') : 'detached', isDetached: !ref })
1078 }
1079 return list
1080}
1081
1082// The default branch: the one origin's HEAD names ("origin/main"), else main, else master, else none.
1083export function trunkOf(branches: string[], originHead: string) {
1084 const named = originHead.replace(/^origin\//, '')
1085 if (named) return named
1086 return ['main', 'master'].find(b => branches.includes(b)) ?? ''
1087}
1088
1089// `lsof -Fpcn`: a "p" line per process, then its command ("c") and its addresses ("n", "*:3000").
1090export function parseListeners(out: string) {
1091 const map = new Map<number, { command: string; ports: number[] }>()
1092 let entry: { command: string; ports: number[] } | undefined
1093 for (const line of out.split('\n')) {
1094 const value = line.slice(1)
1095 if (line[0] === 'p') {
1096 const pid = Number(value)
1097 entry = map.get(pid) ?? { command: '', ports: [] }
1098 map.set(pid, entry)
1099 } else if (line[0] === 'c' && entry) entry.command = value
1100 else if (line[0] === 'n' && entry) {
1101 const port = Number(/:(\d+)$/.exec(value)?.[1])
1102 if (port > 0 && !entry.ports.includes(port)) entry.ports.push(port)
1103 }
1104 }
1105 for (const e of map.values()) e.ports.sort((a, b) => a - b)
1106 return map
1107}
1108
1109// `lsof -a -d cwd -Fn`: each process's working folder.
1110export function parseCwds(out: string) {
1111 const map = new Map<number, string>()
1112 let pid = 0
1113 for (const line of out.split('\n')) {
1114 if (line[0] === 'p') pid = Number(line.slice(1))
1115 else if (line[0] === 'n' && pid) map.set(pid, line.slice(1))
1116 }
1117 return map
1118}
1119
1120// `ps -axo pid=,ppid=,etime=,rss=,%cpu=,command=`.
1121export function parsePs(out: string) {
1122 const map = new Map<number, Proc>()
1123 for (const line of out.split('\n')) {
1124 const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\d+)\s+([\d.,]+)\s+(.*)$/.exec(line)
1125 if (!m) continue
1126 map.set(Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), elapsedMs: elapsed(m[3]!), rssKb: Number(m[4]), cpu: Number(m[5]!.replace(',', '.')), command: m[6]! })
1127 }
1128 return map
1129}
1130
1131// ps's elapsed time, "[[dd-]hh:]mm:ss", in milliseconds.
1132export function elapsed(etime: string) {
1133 const [days, clock] = etime.includes('-') ? etime.split('-') : ['0', etime]
1134 const seconds = clock!.split(':').reduce((n, part) => n * 60 + Number(part), 0)
1135 return (Number(days) * 86_400 + seconds) * 1000
1136}
1137
1138// `git rev-parse --path-format=absolute --show-toplevel --git-common-dir`, and the branch.
1139export function toGitInfo(out: string, branch: string): GitInfo | null {
1140 const [top, common] = out.trim().split('\n')
1141 if (!top || !common) return null
1142 const repo = common.replace(/\/$/, '')
1143 return { top, repo, name: basename(repo.endsWith('/.git') ? repo.slice(0, -5) : repo.replace(/\.git$/, '')), branch }
1144}
1145
1146// A dev server runs from one of the person's folders; apps opened from the Finder run from "/" or their bundle.
1147export const isForeign = (command: string) => FOREIGN.test(command)
1148
1149export function isDevFolder(cwd: string, home: string) {
1150 return home !== '' && cwd.startsWith(`${home}/`) && !cwd.startsWith(`${home}/Library/`) && !cwd.includes('.app/')
1151}
1152
1153// What started the listener: up through the launchers (npm run dev, sh -c, node) to the first process that is not
1154// one, such as the person's terminal shell or Claude. Stopping the launcher stops the whole server.
1155const LAUNCHER = /(^|\/)(node|npm|npx|pnpm|yarn|bun|bunx|deno|uv|poetry|nodemon|tsx|ts-node|next|vite|turbo|concurrently|lerna|nx|python[\d.]*)(\s|$)|(^|\/)(ba|z)?sh -c /
1156export function launcher(pid: number, procs: Map<number, Proc>) {
1157 let at = pid
1158 for (let i = 0; i < 8; i++) {
1159 const parent = procs.get(procs.get(at)?.ppid ?? 0)
1160 if (!parent || parent.pid <= 1 || !LAUNCHER.test(parent.command) || FOREIGN.test(parent.command)) break
1161 at = parent.pid
1162 }
1163 return at
1164}
1165
1166export function descendants(pid: number, procs: Map<number, Proc>) {
1167 const out: number[] = []
1168 const queue = [pid]
1169 while (queue.length) {
1170 const at = queue.shift()!
1171 for (const p of procs.values()) if (p.ppid === at && !out.includes(p.pid)) (out.push(p.pid), queue.push(p.pid))
1172 }
1173 return out
1174}
1175
1176function ancestors(pid: number, procs: Map<number, Proc>, root: number) {
1177 const out: number[] = []
1178 let at = pid
1179 while (at !== root && out.length < 8) {
1180 at = procs.get(at)?.ppid ?? root
1181 out.unshift(at)
1182 }
1183 return out
1184}
1185
1186// What started a server: the nearest of Claude or a terminal app above its launcher. A login shell ("-zsh") with
1187// no app found is a terminal too; a launcher whose parent is gone (launchd took it) is detached.
1188const CLAUDE = /(^|\/)claude(\s|$)|@anthropic-ai\/claude-code/
1189const TERMINALS: [RegExp, string][] = [
1190 [/\/Terminal\.app\//, 'Terminal'],
1191 [/\/iTerm\d?\.app\/|(^|\/)iTerm2(\s|$)/, 'iTerm'],
1192 [/\/Warp\.app\//, 'Warp'],
1193 [/\/Ghostty\.app\//i, 'Ghostty'],
1194 [/\/kitty\.app\/|(^|\/)kitty(\s|$)/, 'kitty'],
1195 [/\/Alacritty\.app\//, 'Alacritty'],
1196 [/\/WezTerm\.app\/|(^|\/)wezterm-gui(\s|$)/i, 'WezTerm'],
1197 [/\/Visual Studio Code\.app\/|(^|\/)Code Helper/, 'VS Code'],
1198 [/\/Cursor\.app\//, 'Cursor'],
1199 [/\/Zed\.app\//, 'Zed'],
1200 [/(^|\/)tmux(:|\s|$)/, 'tmux'],types/index.d.ts 65 lines1// The pane's two sections, each with its own "Stop all".
2export type Section = 'project' | 'elsewhere'
3
4// Something the project can run: a package.json script that serves, or an entry of .claude/launch.json.
5export type Launchable = {
6 id: string
7 name: string
8 script?: string // the package.json script it runs, when it runs one
9 command: string // what it runs, as shown
10 run: string // the shell line that starts it
11 cwd: string
12 source: 'package.json' | 'launch.json'
13 port?: number // when the command or the entry names one
14 description: string // "Next.js app", from the command
15}
16
17// What started a server, from the processes above it: this pane, Claude in this conversation or another one, a
18// terminal (named when it is a known app), or nothing left, its launcher gone ("detached").
19export type Origin = { kind: 'pane' | 'conversation' | 'claude' | 'terminal' | 'detached' | 'unknown'; app?: string }
20
21// A dev server listening on this machine.
22export type Server = {
23 pid: number // the process that listens
24 pids: number[] // everything it runs: the launcher (npm run dev) and its children
25 name: string
26 description: string
27 ports: number[]
28 cwd: string
29 top: string // the working tree it runs from
30 repo: string // the repository, the same for every worktree of it
31 projectName: string
32 branch: string
33 uptimeMs: number
34 rssKb: number
35 cpu: number
36 origin: Origin
37}
38
39// Where this conversation works.
40export type Project = {
41 cwd: string
42 top: string
43 repo: string
44 name: string
45 branch: string
46}
47
48// Another worktree of the conversation's repository: its branch, or none ("detached"), and what it can launch.
49export type Worktree = { top: string; branch: string; isDetached: boolean; launchables: Launchable[] }
50
51export type Snapshot = {
52 project: Project
53 launchables: Launchable[]
54 servers: Server[]
55 worktrees?: Worktree[] // the repository's other worktrees
56 branches?: string[] // its local branches
57 trunk?: string // its default branch ("main"), or "" when none is found
58}
59
60declare module 'claude-code' {
61 interface PluginState {
62 'quiet-server': { snapshot: Snapshot | null; isStatusHidden: boolean; busy: string[]; confirming: Section | null; opened: Record<string, boolean> }
63 }
64}
65