SLOPSHOPPER

quiet-server

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…

newpanespinnercommandtoaststatus
v2.0.0MITupdated 2026-10-20arthurglaizal/quiet-server
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quiet-server
│ ┃ Servers ✕ › fix the failing auth test and add an audit log call │ ┃ This project add find hide count │ ┃ app ⏺ Read(src/auth.ts) │ ┃ Nothing to run here yet. ⎿ Read 6 lines │ ┃ Dev scripts from package.json and ⏺ Update(src/auth.ts) │ ┃ .claude/launch.json show up here. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ──────────── │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Running elsewhere │ ┃ Nothing else is running. ✻ Worked for 42s · done 4:20 PM │ │ › /quiet-server │ ⎿ quiet-server: Servers pane opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ quiet-server: 0/0 here 0 elsewhere

Draws

Pane · Servers
This project add find hide count app Nothing to run here yet. Dev scripts from package.json and .claude/launch.json show up here. ────────────────────────────────────────────────────── Running elsewhere Nothing else is running.
README

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

Quiet Server

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.

What it shows

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.

  • This project, its name under the title
  • The conversation's branch, tagged 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.
  • Other branches, one group each under a chevron: 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.
  • Branches with no worktree: folded on one line, each with Create worktree. A server needs a folder checked out on its branch, so main without one shows the same button.
  • Running elsewhere: every other dev server on the machine, grouped by project, each group headed by its name and the folder it sits in (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).

Install

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.

How to use

ButtonDesktopTerminalWhat it does
Launchfilled play▶Runs the script through your login shell, so it finds node and npm as your terminal does
Copy addresstwo sheets(desktop only)Copies http://localhost:<port>
Open in browserarrow↗Opens it in your default browser
Stopfilled square■Stops the server and everything it started (npm run dev and its children), forcing it after 3 s
Port:3000:3000On the desktop, a label (Copy address copies it); in the terminal, copies the URL
Launch allin the project's title linesameLaunches this branch's dev servers at once (not start, serve or preview, which serve a build); shown when there are two or more
Stop allin each section's title linesameStops every server of the section; press twice to confirm; shown when two or more run
Folda branch's head▾ / ▸Opens or folds that branch's group
Launchon another branch's head▶ launchLaunches that branch's dev servers, from its own worktree
Create worktreeon a branch with no worktreecreate worktreePuts a request in the prompt box: ask Claude to create a worktree for that branch in .claude/worktrees/
Addplus, on the project's title lineaddPuts a request in the prompt box: ask Claude to add a server to .claude/launch.json
Findmagnifier, next to itfindPuts a request in the prompt box: ask Claude to look for servers the project could list
Counta window with its bottom strip, last on the project's title linehide count / show countShows 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.

CommandWhat it does
/quiet-serverOpens or closes the pane
/quiet-server statusHides or shows the count in the status line, as the pane's Count button does. The choice is kept across sessions
/quiet-server demoShows made-up servers (a project with worktrees, a busy machine) to see the layout; again to come back

Limitations

  • macOS only: it reads the ports with lsof and ps.
  • Needs Claude Code 2.1.287 or later. An update can change the mod API.
  • Only processes running from a folder in your home are listed: apps opened from the Finder, and Claude itself, are left out.
  • On the desktop, the first click in the pane can only bring it into focus; click again to press the button.

What it runs and keeps

  • No network requests. Everything it reads is on your machine. Nothing leaves it except on your click: the address you copy goes to the clipboard, Open in browser hands it to your browser, and Add and Find put a request in the prompt box, which you send or not.
  • Every 5 seconds: 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.
  • Only on click: your login shell ($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.
  • Hooks:
  • 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.
  • One value kept on your machine: whether you hid the count.

Repository structure

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

Quiet mods

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

ModDescription
Quiet&nbsp;Token&nbsp;BarYour context window in one quiet line: a patterned bar, a compact button and your session and weekly limits, grey until something gets warm.
Quiet&nbsp;ModelPick the model and effort in one quiet grey row. Or let Magic Router choose for each message.
Quiet&nbsp;CostWhat each reply cost, in one quiet grey line under it: an estimated price, the time and the tokens, and more figures you pick.

Support

If you find my work useful, you can buy me a coffee ☕️

License

MIT — see LICENSE.

Source 2 files
hooks/register.tsx 1659 lines
1// 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 lines
1// 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