The project's dev servers from .claude/launch.json in a pane: start, stop and restart each, see which answer and read their output

Mods for Claude Code. A mod is a Claude Code plugin that adds interface to Claude Code itself (a pane, a slash command) through the engine's hooks API. This repository holds our own mods, forks of others' (each kept under its own license, with a link back), and is a plugin marketplace that installs them.
The panes draw in the desktop app (as a side pane, with charts) and in the terminal (a simpler pane of text cells). Tested on Claude Code 2.1.296, the version CI pins.
| Mod | What it shows | Opens with | Needs |
|---|---|---|---|
overview | the current session at a glance: context, limits, activity, agents, the branches it started and their pull requests, cost | on its own when a session starts; /overview opens it again | git for Workspaces; gh signed in for pull requests |
issues | the open GitHub issues of the session's repository, newest change first, with actions on each | on its own when a session starts; /issues opens it again | a GitHub repository; gh signed in |
launcher | the project's dev servers from .claude/launch.json (the desktop app's preview list), each with its state, URL, Start, Stop and Restart, and its output | on its own when a session starts; /launcher (or /preview) opens it again; /launcher start <name>, /launcher stop <name> | a .claude/launch.json; lsof to tell which ports answer |
lab | for mod authors: 77 experiments probing what the engine draws (SVG gradients and filters, animation, hover, Client modules, every element), with the engine's verdict on each surface in mods/lab/verdicts.json | the index on its own when a session starts; /lab, /lab <id>, /lab gallery | nothing |
Copies of other people's mods, changed here to fit these. Each keeps its original license and author; its README opens with where it came from, the upstream commit it was taken at, and what this fork changed.
| Mod | What it does | Original | License |
|---|---|---|---|
filetree | an IDE-style file tree of the session's folder: git status and lines changed per file, and a shimmer on what Claude reads, writes and commits. /filetree opens it | data-goblin/claude-code-filetree, by Kurt Buhler | MIT |
blast-radius | holds a risky shell command (rm -rf, git reset --hard, git clean, force push, migrations) and shows what it would change, with Proceed and Cancel | anthropics/claude-code-playground, by Anthropic | Apache-2.0 |
overview's Workspaces section.gh auth login), for issues and for the pull requests in overview's Workspaces. Without it overview still draws everything else, and its Workspaces section shows a note in place of pull requests (gh not installed, or gh not authenticated (run gh auth login)); issues shows the same note in place of the list.bun install, then bun build.ts). Installing from the marketplace needs nothing else./overviewOpens by itself when a session starts; /overview brings it back after you close it. Its sections, each folding open and shut:
gh every 30 seconds while any is open.A footer shows the session's share of the machine's cpu, memory and network.
/issuesOpens by itself when a session starts; /issues brings it back. Lists the open issues of the session's repository (read with gh issue list).
gh issue create).gh:refined.refined asks first whether to refine it.sprint/<date> branch, then one PR for your approval with auto-merge off.These prompts encode an opinionated way of working (worktrees, a subagent per issue, an integration branch reviewed by you). Read mods/issues/hooks/register.tsx for their exact wording before you rely on them.
/launcherThe desktop app's preview launcher, in a pane that works in the terminal too. Opens by itself when a session starts; /launcher brings it back. It lists every configuration in the project's .claude/launch.json (the session's folder, or the nearest one above it up to the git root), one row each: its name, its state, its URL once the port answers, and Start, Stop and Restart.
http://localhost:<port>); running elsewhere (green: something this session didn't start answers on the port, such as the desktop app or another session; it can't be started twice); failed (red: it exited with an error, and its row unfolds to show why).runtimeExecutable runtimeArgs… in the configuration's cwd (the project root by default) with PORT set to its port and its env over that. With autoPort, a taken port moves it to the next free one./launcher start <name>, /launcher stop <name>, /launcher restart <name>, /launcher list. Each answers in text, so they work without the pane. /preview does the same.lsof (an HTTP request to each port where lsof isn't installed), every 2 s while the pane is shown, and not at all while it isn't.npm run serve starting a node server, ending the spawn loop ends the grandchild node too, so its port was free at once.The servers it starts are children of the mod: they end when the session ends, and when the mod reloads (/reload-plugins, or a rebuild with hot reloading on). Servers started elsewhere aren't touched; Stop is only for the ones this session started.
At the prompt of a Claude Code session in a terminal:
/plugin marketplace add satoramoto/awesome-claude
/plugin install overview@awesome-claude
/plugin install issues@awesome-claude
Pick a scope when asked (user scope loads the mod in every session). Each mod is active straight away. Or from a shell: claude plugin marketplace add satoramoto/awesome-claude, then claude plugin install overview@awesome-claude.
Run these from a terminal session; a mod installed at user scope then loads in the desktop app too.
An installed mod updates only when its version changes; every release of a mod raises it. To fetch the newest versions by hand, at the prompt:
/plugin marketplace update awesome-claude
/plugin update overview@awesome-claude
or from a shell: claude plugin marketplace update awesome-claude, then claude plugin update overview@awesome-claude (and the same for each mod you installed). The new version loads after a restart or /reload-plugins.
Auto-update is off by default for marketplaces other than Anthropic's. Turn it on in /plugin → Marketplaces → awesome-claude → Enable auto-update, or with "autoUpdate": true next to source in extraKnownMarketplaces in ~/.claude/settings.json:
"extraKnownMarketplaces": {
"awesome-claude": {
"source": { "source": "github", "repo": "satoramoto/awesome-claude" },
"autoUpdate": true
}
}
Loading from a clone (--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS, below) gets no marketplace updates: git pull the clone instead. To pull at every session start, a SessionStart hook in ~/.claude/settings.json:
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "git -C ~/Code/satoramoto/awesome-claude pull -q --ff-only" }] }
]
}
The compiled mods are committed, so a clone runs as is. A folder of plugins passed to --plugin-dir loads each one in it:
git clone https://github.com/satoramoto/awesome-claude ~/Code/satoramoto/awesome-claude
claude --plugin-dir ~/Code/satoramoto/awesome-claude/plugins
git clone https://github.com/satoramoto/awesome-claude ~/Code/satoramoto/awesome-claude
cd ~/Code/satoramoto/awesome-claude
bun install
bun build.ts
Then point every session at the compiled folder, in ~/.claude/settings.json, with the full path to your clone's plugins folder:
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/Code/satoramoto/awesome-claude/plugins",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
With bun run watch running, a save under mods/ (or in zen's kit, with ZEN set) recompiles and the session reloads the mod. These two variables are what this setup uses on Claude Code 2.1.296; they may change in later versions. Don't also install the mods from the marketplace, or each loads twice.
AGENTS.md lists the commands and checks. The project's skills, which Claude Code loads when working in this repository, hold the how-to:
.claude/skills/mod-authoring/: writing a mod in this repository.claude/skills/storybook-prototyping/: designing how it looks in Storybook.claude/skills/headless-verification/: checking it works without a person watching| Folder | What it is |
|---|---|
mods/<name>/ | a mod's source: .claude-plugin/plugin.json, hooks/, types/. Imports the kit as @kit/… |
zen's src/harness/claude-code/kit/ | the kit, from the pinned @satoramoto/zen dev dependency: components (ui.tsx), colors and glyphs (tokens.ts), number formatting (format.ts), icons (icons.ts), timing a mod's own work (perf.ts), skins (skin.ts, skins.ts) |
zen.ts | where zen's kit and bench are: node_modules/@satoramoto/zen, or the zen checkout ZEN names |
plugins/<name>/ | the compiled mods: what Claude Code loads and the marketplace installs. Generated by bun build.ts; don't edit |
.claude-plugin/marketplace.json | the marketplace: each mod in plugins/, at its plugin.json version |
playground/ | a browser page that draws every mod live with sample data, on each surface, through zen's bench; playground/fixtures/ holds the sample data |
stories/ | Storybook stories: each mod's scenarios, the lab and the engine experiments |
Each mod is versioned on its own in its plugin.json. A new mod also gets an entry in .claude-plugin/marketplace.json; CI fails if a mod is missing there or its version differs from its plugin.json. Any change to a mod's compiled output in plugins/<mod>/ (a new zen pin that changes the kit included) raises its version, in plugin.json and the marketplace, since installs only update when it changes; CI fails a pull request that changes a mod's output without raising its version.
bun run playground
At http://localhost:5173 (set PORT for another). Every mod's ui.render hooks, drawn with the scenarios in playground/fixtures/<mod>.ts, side by side in desktop light, desktop dark and terminal, at any pane width. On each save it also compiles and runs claude plugin validate plugins/<mod>, and shows the result in the bar.
bun run storybook
At http://localhost:6006 (set PORT for another). Each mod scenario (stories/<mod>.stories.ts, from the playground's fixtures), drawn by zen's bench. The toolbar picks the surface (desktop or terminal), the theme, the frame and the pane width. The kit's own components and the skins have their stories in zen's Storybook, under Harness/Claude Code. Storybook is for how things look; the playground is for wiring up a whole mod with the engine's checks running.
The engine reads a mod's source as it is and refuses any import from outside the mod's folder (symlinks included). build.ts copies zen's kit into each compiled mod and rewrites @kit/… to relative paths; nothing else changes, so the output is still readable source and passes claude plugin validate.
What the desktop accepts, found by probing the engine's validator (first on 2.1.293; the mods are tested on 2.1.296): whole percentages only (25.5% is refused); flexGrow up to 10000; fractional heights and margins; no Box hover. Borders: round draws rounded corners, and single, double, bold and classic draw; dashed doesn't. SVG is drawn as an image the page scales, so it blurs as the pane resizes: draw with boxes and text.
What it paints (measured on the real pane, 2.1.295): a horizontal unit is a column (~7.8px), height a row (~19px), but vertical margin, padding and row gap half a row (~9.5px); a transparent border colour draws in the text's colour; a Button draws its label alone, never its children. zen's docs/harness-claude-code.md ("What the engine and the desktop accept") has the details, and Storybook draws by the same rules.
MIT, except the forked mods, which keep their own licenses: mods/filetree is MIT, copyright Kurt Buhler (LICENSE), and mods/blast-radius is Apache-2.0, copyright Anthropic PBC (LICENSE).
hooks/register.tsx 870 lines1import { atom, read, update } from 'claude-code'
2import type { CommandRunInput, EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register } from 'claude-code'
3
4import { holdButtons } from '@kit/ui'
5
6import type { Adopted, KillConfirm, LaunchConfig, LaunchFile, Server, Status } from '../types'
7import { LOOK, commandOf, drawnServers, renderPane, urlOf } from './view'
8import type { PaneElements } from './view'
9
10const PANE = 'launcher'
11const TITLE = 'Launcher'
12/** How many lines of a server's output are kept. */
13const LOG_LINES = 200
14
15const file = atom({ plugin: 'launcher', key: 'file' } as const, null)
16const servers = atom({ plugin: 'launcher', key: 'servers' } as const, {})
17const open = atom({ plugin: 'launcher', key: 'open' } as const, [])
18const adopted = atom({ plugin: 'launcher', key: 'adopted' } as const, {})
19const confirm = atom({ plugin: 'launcher', key: 'confirm' } as const, null)
20/** Whether a wide app pane flows its rows into columns; the footer's switch flips it. */
21const columns = atom({ plugin: 'launcher', key: 'columns' } as const, true)
22
23// ---- reading .claude/launch.json ----
24
25const trim = (dir: string) => (dir.length > 1 ? dir.replace(/[\\/]+$/, '') : dir)
26const parent = (dir: string) => {
27 const cut = Math.max(dir.lastIndexOf('/'), dir.lastIndexOf('\\'))
28 return cut <= 0 ? dir.slice(0, cut + 1) || dir : dir.slice(0, cut)
29}
30/** `rel` under `root`, unless it's absolute already. */
31export const resolvePath = (root: string, rel: string) => (/^([\\/]|[A-Za-z]:)/.test(rel) ? rel : `${trim(root)}/${rel.replace(/^\.\/?/, '')}`.replace(/\/$/, ''))
32
33/** The configurations of a launch.json's text; entries missing a name, an executable or a port are left out. */
34export const parseLaunch = (text: string, root: string, at: number): LaunchFile => {
35 let json: any
36 try {
37 json = JSON.parse(text)
38 } catch {
39 return { root, configs: [], at, note: 'Unreadable .claude/launch.json (not JSON).' }
40 }
41 const configs: LaunchConfig[] = []
42 for (const c of Array.isArray(json?.configurations) ? json.configurations : []) {
43 const port = Number(c?.port)
44 if (typeof c?.name !== 'string' || !c.name || typeof c.runtimeExecutable !== 'string' || !Number.isInteger(port) || port <= 0) continue
45 if (configs.some(x => x.name === c.name)) continue
46 configs.push({
47 name: c.name,
48 runtimeExecutable: c.runtimeExecutable,
49 runtimeArgs: Array.isArray(c.runtimeArgs) ? c.runtimeArgs.map(String) : [],
50 port,
51 ...(typeof c.cwd === 'string' && c.cwd ? { cwd: c.cwd } : {}),
52 ...(c.env && typeof c.env === 'object' ? { env: Object.fromEntries(Object.entries(c.env).map(([k, v]) => [k, String(v)])) } : {}),
53 ...(c.autoPort ? { autoPort: true } : {}),
54 })
55 }
56 return { root, configs, at }
57}
58
59/** The folder holding `.claude/launch.json`: the session's, or the nearest above it up to the git root. */
60const findRoot = async ($: EngineInterface): Promise<string | null> => {
61 let dir = trim(await $.session.cwd())
62 for (let i = 0; i < 64; i++) {
63 if (await $.fs.exists(`${dir}/.claude/launch.json`).catch(() => false)) return dir
64 if (await $.fs.exists(`${dir}/.git`).catch(() => false)) return null
65 const up = parent(dir)
66 if (up === dir) return null
67 dir = up
68 }
69 return null
70}
71
72const loadFile = async ($: EngineInterface): Promise<LaunchFile> => {
73 const at = Date.now()
74 const root = await findRoot($)
75 if (!root) return { root: trim(await $.session.cwd()), configs: [], at, note: 'No .claude/launch.json in this project.' }
76 try {
77 return parseLaunch(String(await $.fs.read(`${root}/.claude/launch.json`)), root, at)
78 } catch {
79 return { root, configs: [], at, note: 'Could not read .claude/launch.json.' }
80 }
81}
82
83// ---- which ports answer ----
84
85/** The TCP ports `lsof -nP -iTCP -sTCP:LISTEN -Fn` lists as listening. */
86export const portsFromLsof = (stdout: string): Set<number> => {
87 const ports = new Set<number>()
88 for (const line of stdout.split('\n')) {
89 const m = /^n.*:(\d+)$/.exec(line.trim())
90 if (m) ports.add(Number(m[1]))
91 }
92 return ports
93}
94
95/**
96 * Every process listening on each TCP port, from `lsof -Fpn`'s `p<pid>` and
97 * `n<addr>:<port>` lines, in lsof's order: one port can have several (an IPv4
98 * and an IPv6 socket from different processes).
99 */
100export const pidsFromLsof = (stdout: string): Map<number, Set<number>> => {
101 const pids = new Map<number, Set<number>>()
102 let pid = 0
103 for (const line of stdout.split('\n')) {
104 const l = line.trim()
105 if (l.startsWith('p')) pid = Number(l.slice(1)) || 0
106 const m = /^n.*:(\d+)$/.exec(l)
107 if (!m || !pid) continue
108 const port = Number(m[1])
109 const set = pids.get(port) ?? new Set<number>()
110 set.add(pid)
111 pids.set(port, set)
112 }
113 return pids
114}
115
116/** `ps -o pid=,%cpu=,rss=` lines: each process's CPU percent and resident memory in bytes. */
117export const usageFromPs = (stdout: string): Map<number, { cpu: number; rss: number }> => {
118 const usage = new Map<number, { cpu: number; rss: number }>()
119 for (const line of stdout.split('\n')) {
120 const [pid, cpu, rss] = line.trim().split(/\s+/).map(Number)
121 if (pid && Number.isFinite(cpu) && Number.isFinite(rss)) usage.set(pid, { cpu: cpu!, rss: rss! * 1024 })
122 }
123 return usage
124}
125
126/** The last probe: every port something listens on, and the processes on each where lsof said. */
127let listening = new Set<number>()
128let holders = new Map<number, Set<number>>()
129/** The process a row names for a port: the first lsof listed on it. */
130const pidOf = (port: number): number | undefined => holders.get(port)?.values().next().value
131/** Whether a probe has run since the module loaded. */
132let probed = false
133/** Whether the last probe's pids came from lsof (rather than none, from HTTP). */
134let byLsof = false
135
136/** Which ports answer: lsof's listening sockets, or, without lsof, an HTTP request to each configuration's port. */
137const probe = async ($: EngineInterface, wanted: number[]) => {
138 try {
139 const run = await $.process.run(['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpn'], { timeoutMs: 5_000 })
140 // lsof exits 1 when nothing listens at all.
141 listening = portsFromLsof(run.stdout)
142 holders = pidsFromLsof(run.stdout)
143 byLsof = true
144 } catch {
145 const answers = await Promise.all(wanted.map(p => $.http.fetch(urlOf(p)).then(() => p, () => null)))
146 listening = new Set(answers.filter((p): p is number => p !== null))
147 holders = new Map()
148 byLsof = false
149 }
150 probed = true
151}
152
153/** How many CPU samples a server keeps: two minutes of ticks. */
154const USAGE_SAMPLES = 60
155/** Each answering server's CPU samples and last memory, by configuration name. */
156const usage = new Map<string, { cpu: number[]; rss: number }>()
157
158/**
159 * One CPU and memory sample of each answering server's listening process: one
160 * `ps` for all of them, after the probe that found their pids. Servers that
161 * stopped answering lose their samples. Without lsof's pids or ps, nothing.
162 */
163const sample = async ($: EngineInterface, configs: LaunchConfig[]) => {
164 const pids = new Map<string, number>()
165 for (const c of configs) {
166 const pid = pidOf(children.get(c.name)?.port ?? c.port)
167 if (pid) pids.set(c.name, pid)
168 }
169 for (const name of [...usage.keys()]) if (!pids.has(name)) usage.delete(name)
170 if (!pids.size) return
171 let got: Map<number, { cpu: number; rss: number }>
172 try {
173 got = usageFromPs((await $.process.run(['ps', '-o', 'pid=,%cpu=,rss=', '-p', [...new Set(pids.values())].join(',')], { timeoutMs: 5_000 })).stdout)
174 } catch {
175 return
176 }
177 for (const [name, pid] of pids) {
178 const now = got.get(pid)
179 if (!now) {
180 usage.delete(name)
181 continue
182 }
183 const u = usage.get(name) ?? { cpu: [], rss: 0 }
184 u.cpu = [...u.cpu, now.cpu].slice(-USAGE_SAMPLES)
185 u.rss = now.rss
186 usage.set(name, u)
187 }
188}
189
190/** The first port from `from` up that nothing listens on. */
191const freePort = (from: number) => {
192 let port = from
193 while (listening.has(port) && port < 65_535) port++
194 return port
195}
196
197// ---- whose process holds a port ----
198
199/**
200 * A listening process as ps and lsof tell it: when it started and its command
201 * line (together they tell it from a later process given the same pid), its
202 * parents' command lines (nearest first) and its working directory.
203 */
204export type Identity = { pid: number; start: string; command: string; chain: string[]; cwd?: string }
205
206type Row = { ppid: number; start: string; command: string }
207
208/** The columns every ps read asks for, in the C locale so `lstart` reads the same each time. */
209const PS_COLUMNS = ['-o', 'pid=,ppid=,lstart=,command='] as const
210const PS_ENV = { env: { LC_ALL: 'C' }, timeoutMs: 5_000 }
211
212/** `ps -o pid=,ppid=,lstart=,command=` lines: each process's parent, start time (`Mon Oct 12 10:00:00 2026`) and command line. */
213export const tableFromPs = (stdout: string): Map<number, Row> => {
214 const table = new Map<number, Row>()
215 for (const line of stdout.split('\n')) {
216 const m = /^\s*(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\S+\s+\d+:\d+:\d+\s+\d+)\s+(.*)$/.exec(line)
217 if (m) table.set(Number(m[1]), { ppid: Number(m[2]), start: m[3]!.replace(/\s+/g, ' '), command: m[4]!.trim() })
218 }
219 return table
220}
221
222/** `lsof -a -d cwd -p <pids> -Fpn` output: each process's working directory. */
223export const cwdsFromLsof = (stdout: string): Map<number, string> => {
224 const cwds = new Map<number, string>()
225 let pid = 0
226 for (const line of stdout.split('\n')) {
227 const l = line.trim()
228 if (l.startsWith('p')) pid = Number(l.slice(1)) || 0
229 else if (l.startsWith('n') && pid && !cwds.has(pid)) cwds.set(pid, l.slice(1))
230 }
231 return cwds
232}
233
234/** How far up its parents a process's command lines are read: npm, bun or a wrapper start the real server a level or two down. */
235const CHAIN = 5
236
237/** Who each listening pid is, read once per pid (a pid's command line and folder don't change while it lives). */
238const ids = new Map<number, Identity>()
239
240/**
241 * Reads who holds each configuration's port that this session neither started
242 * nor took over: one `ps` for the process table and one `lsof` for their
243 * working directories, only for pids not read before. Without ps, nothing:
244 * the rows can't tell whose a port is, and say it answers.
245 */
246const identify = async ($: EngineInterface, ports: number[]) => {
247 const pids = [...new Set(ports.flatMap(p => [...(holders.get(p) ?? [])]))]
248 for (const pid of [...ids.keys()]) if (!pids.includes(pid)) ids.delete(pid)
249 const fresh = pids.filter(p => !ids.has(p))
250 if (!fresh.length) return
251 let table: Map<number, Row>
252 try {
253 table = tableFromPs((await $.process.run(['ps', '-A', ...PS_COLUMNS], PS_ENV)).stdout)
254 } catch {
255 return
256 }
257 let cwds = new Map<number, string>()
258 try {
259 cwds = cwdsFromLsof((await $.process.run(['lsof', '-a', '-d', 'cwd', '-p', fresh.join(','), '-Fpn'], { timeoutMs: 5_000 })).stdout)
260 } catch {
261 // Without folders, the command lines alone tell.
262 }
263 for (const pid of fresh) {
264 const own = table.get(pid)
265 if (!own) continue
266 const chain: string[] = []
267 let up = own.ppid
268 for (let i = 0; i < CHAIN && up > 1; i++) {
269 const p = table.get(up)
270 if (!p) break
271 chain.push(p.command)
272 up = p.ppid
273 }
274 const cwd = cwds.get(pid)
275 ids.set(pid, { pid, start: own.start, command: own.command, chain, ...(cwd ? { cwd: trim(cwd) } : {}) })
276 }
277}
278
279const base = (path: string) => path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
280
281/** Whether a command line runs the configuration: its executable (by name, anywhere in the line) followed by exactly its arguments, to the end. */
282export const runsConfig = (command: string, c: LaunchConfig) => {
283 const words = command.trim().split(/\s+/)
284 const exe = base(c.runtimeExecutable)
285 for (let i = 0; i + c.runtimeArgs.length < words.length; i++) {
286 if (base(words[i]!) !== exe) continue
287 const rest = words.slice(i + 1)
288 if (rest.length === c.runtimeArgs.length && rest.every((w, j) => w === c.runtimeArgs[j])) return true
289 }
290 return false
291}
292
293/** The folder a configuration's server works in: its `cwd`, then a `--cwd` or `--prefix` among its arguments (bun, npm). */
294export const workdirOf = (c: LaunchConfig, root: string) => {
295 let dir = c.cwd ? resolvePath(root, c.cwd) : trim(root)
296 const a = c.runtimeArgs
297 for (let i = 0; i < a.length; i++) {
298 const eq = /^--(?:cwd|prefix)=(.+)$/.exec(a[i]!)
299 if (eq) dir = resolvePath(dir, eq[1]!)
300 else if ((a[i] === '--cwd' || a[i] === '--prefix') && a[i + 1]) dir = resolvePath(dir, a[i + 1]!)
301 }
302 return trim(dir)
303}
304
305/**
306 * How surely a process is a configuration's server: 2 when it or a parent runs
307 * the configuration's command, 1 when it works in the configuration's folder,
308 * 0 when neither.
309 */
310export const matchScore = (id: Identity, c: LaunchConfig, root: string) => {
311 if ([id.command, ...id.chain].some(cmd => runsConfig(cmd, c))) return 2
312 if (id.cwd && id.cwd === workdirOf(c, root)) return 1
313 return 0
314}
315
316// ---- the servers this session started ----
317
318type Child = {
319 stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>
320 port: number
321 startedAt: number
322 /** Stop was asked for, so its end isn't a failure. */
323 stopping: boolean
324 /** Settles once the child is gone and its state written. */
325 done: Promise<void>
326}
327
328/**
329 * The children this module started, by configuration name. Each lives as long
330 * as its spawn loop: Stop ends the loop, and the module unloading (the session
331 * ending, a reload) kills every one.
332 */
333const children = new Map<string, Child>()
334/** Each server's output: whole lines and the line still being written. */
335const logs = new Map<string, { lines: string[]; partial: string }>()
336/** Names whose Start is under way but not yet spawned, so a second Start can't spawn twice. */
337const starting = new Set<string>()
338/** How long Stop waits for a child's loop to end before answering anyway. */
339const STOP_WAIT = 3_000
340
341const ANSI = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07]*\x07/g
342
343/** Adds output to a server's log, a line at a time, keeping the last LOG_LINES. */
344export const appendLog = (log: { lines: string[]; partial: string }, text: string) => {
345 const parts = (log.partial + text.replace(ANSI, '')).split('\n')
346 log.partial = parts.pop() ?? ''
347 for (const p of parts) log.lines.push(p.replace(/\r$/, '').replace(/^.*\r/, ''))
348 if (log.lines.length > LOG_LINES) log.lines.splice(0, log.lines.length - LOG_LINES)
349}
350
351const logOf = (name: string) => {
352 const log = logs.get(name)
353 if (!log) return undefined
354 return log.partial ? [...log.lines, log.partial].slice(-LOG_LINES) : [...log.lines]
355}
356
357/** The writes of `servers` that read it first, one at a time. */
358let chain: Promise<unknown> = Promise.resolve()
359const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
360 const p = chain.then(fn)
361 chain = p.catch(() => undefined)
362 return p
363}
364
365/** Every configuration's server as it stands: what this session runs, what answers, how the last one ended. */
366const reconcile = ($: EngineInterface) => serial(() => reconcileNow($))
367
368const reconcileNow = async ($: EngineInterface) => {
369 const f = await read($, file)
370 const old = await read($, servers)
371 const took = await read($, adopted)
372 const configs = f?.configs ?? []
373 const root = f?.root ?? ''
374 // A taken-over server whose process left its port, or whose pid is now a later process's, has ended: forget it.
375 const gone = Object.keys(took).filter(name => {
376 // Before the first probe (a reload) nothing is known to have ended.
377 if (!probed) return false
378 const a = took[name]!
379 const held = holders.get(a.port)
380 const id = ids.get(a.pid)
381 return (
382 !configs.some(c => c.name === name) ||
383 children.has(name) ||
384 !listening.has(a.port) ||
385 // Without lsof's pids (it answers to HTTP), there's no telling it left.
386 (held !== undefined && !held.has(a.pid)) ||
387 (id !== undefined && (id.start !== a.start || id.command !== a.command))
388 )
389 })
390 if (gone.length) await update($, adopted, a => Object.fromEntries(Object.entries(a).filter(([n]) => !gone.includes(n))))
391 const kept = Object.fromEntries(Object.entries(took).filter(([n]) => !gone.includes(n)))
392 // Ports a server of this session's (started or taken over) holds, and whose it is.
393 const owner = new Map<number, string>()
394 for (const [name, child] of children) owner.set(child.port, name)
395 for (const [name, a] of Object.entries(kept)) if (!owner.has(a.port)) owner.set(a.port, name)
396 // On each port held elsewhere, how surely the best configuration on it matches its process.
397 const best = new Map<number, number>()
398 for (const c of configs) {
399 const id = ids.get(pidOf(c.port) ?? 0)
400 if (id) best.set(c.port, Math.max(best.get(c.port) ?? 0, matchScore(id, c, root)))
401 }
402 const next: Record<string, Server> = {}
403 for (const c of configs) {
404 const prev = old[c.name]
405 const child = children.get(c.name)
406 const took1 = kept[c.name]
407 const holder = pidOf(c.port)
408 const id = holder ? ids.get(holder) : undefined
409 let status: Status
410 if (starting.has(c.name)) status = 'starting'
411 else if (child) status = listening.has(child.port) ? 'running' : 'starting'
412 else if (took1) status = 'running'
413 else if (listening.has(c.port)) {
414 const other = owner.get(c.port)
415 // Another configuration's server of this session's holds it; or a process
416 // that runs a configuration on this port better than this one, or none.
417 if (other !== undefined && other !== c.name) status = 'taken'
418 else if (!id) status = 'elsewhere'
419 else status = matchScore(id, c, root) > 0 && matchScore(id, c, root) === best.get(c.port) ? 'elsewhere' : 'taken'
420 } else if (prev?.status === 'failed') status = 'failed'
421 else status = 'stopped'
422 const used = status === 'running' || status === 'elsewhere' ? usage.get(c.name) : undefined
423 const held = !child && (status === 'elsewhere' || status === 'taken' || Boolean(took1))
424 const pid = took1?.pid ?? (held ? holder : undefined)
425 const command = took1?.command ?? (held ? id?.command : undefined)
426 next[c.name] = {
427 status,
428 port: child?.port ?? took1?.port ?? c.port,
429 log: logOf(c.name) ?? prev?.log ?? [],
430 code: child ? null : (prev?.code ?? null),
431 startedAt: child?.startedAt ?? 0,
432 ...(used ? { cpu: used.cpu, rss: used.rss } : {}),
433 ...(pid ? { pid } : {}),
434 ...(command ? { command } : {}),
435 ...(took1 ? { adopted: true } : {}),
436 }
437 }
438 // Only a change the pane would draw redraws: a new CPU sample that reads the same doesn't.
439 if (JSON.stringify(drawnServers(next)) !== JSON.stringify(drawnServers(old))) await update($, servers, () => next)
440}
441
442/** Starts a configuration's server; answers what happened, in a line. */
443const start = async ($: EngineInterface, name: string): Promise<string> => {
444 const f = await read($, file)
445 const c = f?.configs.find(x => x.name === name)
446 if (!f || !c) return `No configuration named "${name}"${f?.configs.length ? ` (have: ${f.configs.map(x => x.name).join(', ')})` : ''}.`
447 if (children.has(name)) return `${name} is already running from this session on ${urlOf(children.get(name)!.port)}.`
448 if (starting.has(name)) return `${name} is already starting.`
449 // Reserved before the first await, so a second Start meanwhile spawns nothing.
450 starting.add(name)
451 try {
452 return await spawnIt($, name, f, c)
453 } finally {
454 starting.delete(name)
455 await reconcile($).catch(() => undefined)
456 }
457}
458
459const spawnIt = async ($: EngineInterface, name: string, f: LaunchFile, c: LaunchConfig): Promise<string> => {
460 await reconcile($)
461 await probe($, [c.port])
462 let port = c.port
463 if (listening.has(port)) {
464 if (!c.autoPort) {
465 return `${name}: port ${port} is in use by another process (started elsewhere); not starting it twice.`
466 }
467 port = freePort(port)
468 }
469 const argv = [c.runtimeExecutable, ...c.runtimeArgs]
470 const stream = $.process.spawn({ argv, cwd: c.cwd ? resolvePath(f.root, c.cwd) : f.root, env: { ...c.env, PORT: String(port) } })
471 logs.set(name, { lines: [`$ ${commandOf(c)}`], partial: '' })
472 const child: Child = { stream, port, startedAt: Date.now(), stopping: false, done: Promise.resolve() }
473 children.set(name, child)
474 child.done = run($, name, child).catch(() => undefined)
475 return `Starting ${name} on ${urlOf(port)} (${commandOf(c)}).`
476}
477
478/** Reads the child's output until it ends, then writes how it ended. */
479const run = async ($: EngineInterface, name: string, child: Child) => {
480 const log = logs.get(name)!
481 let result: ProcessSpawnResult | undefined
482 let cannot = false
483 try {
484 for (;;) {
485 const piece = await child.stream.next()
486 if (piece.done) {
487 result = piece.value
488 break
489 }
490 appendLog(log, piece.value.text)
491 }
492 } catch (err) {
493 cannot = !child.stopping
494 if (cannot) appendLog(log, `\n${err instanceof Error ? err.message : String(err)}\n`)
495 }
496 if (children.get(name) === child) children.delete(name)
497 const failed = !child.stopping && (cannot || result?.code !== 0)
498 if (!child.stopping && result) appendLog(log, `\n${result.signal ? `ended by ${result.signal}` : `exited with code ${result.code}`}\n`)
499 const ended: Server = { status: failed ? 'failed' : 'stopped', port: child.port, log: logOf(name) ?? [], code: result?.code ?? null, startedAt: 0 }
500 // In turn with reconcile, so neither writes over the other with what it read before.
501 await serial(async () => {
502 // A newer child of the same name owns the row now (or a Restart's Start is under way).
503 if (children.has(name) || (child.stopping && starting.has(name))) return
504 await update($, servers, s => ({ ...s, [name]: ended }))
505 // A failure unfolds its row, so its output is in view.
506 if (failed) await update($, open, o => (o.includes(name) ? o : [...o, name]))
507 })
508}
509
510/** Ends a server this session started; answers what happened, in a line. */
511const stop = async ($: EngineInterface, name: string): Promise<string> => {
512 const child = children.get(name)
513 if (!child) {
514 const took = (await read($, adopted))[name]
515 if (took) {
516 const check = await recheck($, took, { sole: false, released: false })
517 if (!check.ok) {
518 // It isn't the process taken over any more (or it left its port): nothing of this session's to stop.
519 await forget($, name)
520 await settle($, took.port)
521 return `${name}: not stopped, and no longer taken over: ${check.why}.`
522 }
523 const out = await terminate($, took, { sole: false })
524 if (out.ended) await forget($, name)
525 await settle($, took.port)
526 return out.ended ? `Stopped ${name}.` : `${name}: Stop failed: ${out.why}; still taken over.`
527 }
528 const s = (await read($, servers))[name]
529 return s?.status === 'elsewhere'
530 ? `${name} on ${urlOf(s.port)} was started elsewhere (another session or the desktop app); take it over or kill it from the pane, or stop it there.`
531 : `${name} isn't running from this session.`
532 }
533 child.stopping = true
534 // Leaving the loop kills the child: return() ends the stream the loop reads.
535 void child.stream.return({ code: null, signal: 'SIGTERM' }).catch(() => undefined)
536 // A server that ignores the end mustn't hang Stop.
537 const wait = new AbortController()
538 await Promise.race([child.done, $.clock.sleep(STOP_WAIT, { signal: wait.signal }).catch(() => undefined)])
539 wait.abort()
540 if (children.get(name) === child) children.delete(name)
541 await probe($, [child.port]).catch(() => undefined)
542 await reconcile($)
543 return `Stopped ${name}.`
544}
545
546const restart = async ($: EngineInterface, name: string) => {
547 if (children.has(name) || (await read($, adopted))[name]) await stop($, name)
548 return start($, name)
549}
550
551// ---- servers started elsewhere: take over, kill ----
552
553/** How long Kill waits after SIGTERM before SIGKILL, and how often it looks meanwhile. */
554const KILL_WAIT = 2_000
555const KILL_LOOK = 250
556/** How long a first press of Kill waits for the second that confirms it. */
557export const CONFIRM_MS = 4_000
558
559/** Whether the process lives: `kill -0` succeeds. */
560const alive = async ($: EngineInterface, pid: number) => {
561 try {
562 return (await $.process.run(['kill', '-0', String(pid)], { timeoutMs: 5_000 })).exitCode === 0
563 } catch {
564 return false
565 }
566}
567
568/** A process recorded to be signalled later: the port it listened on, and who it was then. */
569type Recorded = { pid: number; port: number; start?: string; command: string }
570
571type Check = { ok: true } | { ok: false; gone: boolean; why: string }
572
573/** `process 1`, `processes 1 and 2`, `processes 1, 2 and 3`. */
574const listPids = (pids: Iterable<number>) => {
575 const a = [...pids]
576 return a.length === 1 ? `process ${a[0]}` : `processes ${a.slice(0, -1).join(', ')} and ${a[a.length - 1]}`
577}
578
579/**
580 * Whether a recorded process is still the one to signal, read afresh: ps's
581 * start time and command line for its pid must be the recorded ones (a pid
582 * freed and given to a later process mustn't be signalled), and lsof must list
583 * it on its port. `sole`: no other process listens there either (Kill ends one
584 * process a press). `released`: its port may be free (a process shutting down
585 * after SIGTERM closes its socket first). `gone` says the recorded process has
586 * ended (its pid is no one's, or a later process's).
587 */
588const recheck = async ($: EngineInterface, was: Recorded, o: { sole: boolean; released: boolean }): Promise<Check> => {
589 if (!was.start) return { ok: false, gone: false, why: `process ${was.pid}'s start time was never read, so it can't be told from a later process given its pid` }
590 await probe($, [was.port])
591 if (!byLsof) return { ok: false, gone: false, why: `lsof couldn't say which process listens on port ${was.port}` }
592 let row: Row | undefined
593 try {
594 const run = await $.process.run(['ps', ...PS_COLUMNS, '-p', String(was.pid)], PS_ENV)
595 row = tableFromPs(run.stdout).get(was.pid)
596 } catch {
597 return { ok: false, gone: false, why: `ps couldn't say who process ${was.pid} is now` }
598 }
599 if (!row) return { ok: false, gone: true, why: `process ${was.pid} has ended` }
600 if (row.start !== was.start || row.command !== was.command) {
601 return { ok: false, gone: true, why: `process ${was.pid} is now another process (started ${row.start}: ${row.command})` }
602 }
603 const held = holders.get(was.port) ?? new Set<number>()
604 if (!held.has(was.pid)) {
605 if (o.released && !held.size) return { ok: true }
606 return { ok: false, gone: false, why: held.size ? `port ${was.port} is now held by ${listPids(held)}, not process ${was.pid}` : `process ${was.pid} no longer listens on port ${was.port}` }
607 }
608 if (o.sole && held.size > 1) return { ok: false, gone: false, why: severalOn(was.port, held) }
609 return { ok: true }
610}
611
612const severalOn = (port: number, held: Set<number>) => `${held.size} processes listen on port ${port} (${listPids(held)}); Kill ends one process, so end them from a terminal`
613
614/**
615 * Ends a process this module didn't spawn, already rechecked: SIGTERM, then
616 * SIGKILL if it still lives after KILL_WAIT and is still the recorded process.
617 * The engine has no signal call of its own, so `kill` runs on the host as the
618 * session's user (it can end only that user's processes).
619 */
620const terminate = async ($: EngineInterface, was: Recorded, o: { sole: boolean }): Promise<{ ended: true } | { ended: false; why: string }> => {
621 const pid = was.pid
622 if (!(pid > 1)) return { ended: false, why: `process ${pid} can't be signalled` }
623 await $.process.run(['kill', '-TERM', String(pid)], { timeoutMs: 5_000 }).catch(() => undefined)
624 for (let waited = 0; waited < KILL_WAIT; waited += KILL_LOOK) {
625 if (!(await alive($, pid))) return { ended: true }
626 await $.clock.sleep(KILL_LOOK).catch(() => undefined)
627 }
628 if (!(await alive($, pid))) return { ended: true }
629 const check = await recheck($, was, { sole: o.sole, released: true })
630 if (!check.ok) return check.gone ? { ended: true } : { ended: false, why: `process ${pid} outlived SIGTERM and wasn't sent SIGKILL: ${check.why}` }
631 await $.process.run(['kill', '-KILL', String(pid)], { timeoutMs: 5_000 }).catch(() => undefined)
632 return (await alive($, pid)) ? { ended: false, why: `could not end process ${pid} (it may be another user's)` } : { ended: true }
633}
634
635/** Forgets a taken-over server. */
636const forget = ($: EngineInterface, name: string) => update($, adopted, a => Object.fromEntries(Object.entries(a).filter(([n]) => n !== name)))
637
638/** Reads every configuration's port and its holders afresh, then the rows. */
639const settle = async ($: EngineInterface, port: number) => {
640 const ports = [...new Set([port, ...((await read($, file))?.configs ?? []).map(c => c.port)])]
641 await probe($, ports).catch(() => undefined)
642 await identify($, ports).catch(() => undefined)
643 await reconcile($)
644}
645
646/** The process holding a configuration's port right now, read afresh, and who it is. */
647const holderOf = async ($: EngineInterface, port: number) => {
648 const ports = [...new Set([port, ...((await read($, file))?.configs ?? []).map(c => c.port)])]
649 await probe($, ports)
650 await identify($, ports)
651 const pid = pidOf(port)
652 return pid ? { pid, held: holders.get(port)!, id: ids.get(pid) } : null
653}
654
655/**
656 * Takes over the server answering on a configuration's port: records its pid,
657 * so the row reads running and Stop signals it. Its output stays where it goes
658 * (the terminal or app that started it): the engine can't read another
659 * process's standard output.
660 */
661const adopt = async ($: EngineInterface, name: string): Promise<string> => {
662 const c = (await read($, file))?.configs.find(x => x.name === name)
663 if (!c) return `No configuration named "${name}".`
664 if (children.has(name)) return `${name} is already running from this session.`
665 const holder = await holderOf($, c.port)
666 if (!holder) return `${name}: nothing found listening on port ${c.port} to take over.`
667 // Stop will signal it only if it's still this process, so who it is must be known now.
668 if (!holder.id) return `${name}: ps couldn't say who process ${holder.pid} is, so it can't be taken over.`
669 const took: Adopted = { pid: holder.pid, port: c.port, start: holder.id.start, command: holder.id.command, at: await $.clock.now() }
670 await update($, adopted, a => ({ ...a, [name]: took }))
671 await reconcile($)
672 return `Took over ${name} (process ${holder.pid}) on ${urlOf(c.port)}.`
673}
674
675/**
676 * Kill, in two presses: the first arms it on the row for CONFIRM_MS (the row
677 * asks `Kill PID 1234?`), the second within that time ends the process holding
678 * the port, if it's still the process the first press saw (see `recheck`). A
679 * press after it lapsed arms it again. A port several processes listen on
680 * isn't armed: one press signals one process.
681 */
682const killPress = async ($: EngineInterface, name: string): Promise<string> => {
683 const c = (await read($, file))?.configs.find(x => x.name === name)
684 if (!c) return `No configuration named "${name}".`
685 const s = (await read($, servers))[name]
686 if (!s?.pid) return `${name}: no process found on port ${c.port}.`
687 const now = await $.clock.now()
688 const armed = await read($, confirm)
689 if (armed && armed.name === name && armed.pid === s.pid && armed.until > now) {
690 await update($, confirm, () => null)
691 return kill($, name, armed)
692 }
693 // Arming: who holds the port, read afresh, is what the second press must find.
694 const holder = await holderOf($, s.port).catch(() => null)
695 const refuse = async (why: string) => {
696 await reconcile($)
697 return `${name}: not killed: ${why}.`
698 }
699 if (!holder) return refuse(`nothing listens on port ${s.port} now`)
700 if (holder.held.size > 1) return refuse(severalOn(s.port, holder.held))
701 if (holder.pid !== s.pid) return refuse(`port ${s.port} is now held by process ${holder.pid}; look again`)
702 if (!holder.id) return refuse(`ps couldn't say who process ${holder.pid} is`)
703 const until = now + CONFIRM_MS
704 await update($, confirm, () => ({ name, pid: holder.pid, port: s.port, start: holder.id!.start, command: holder.id!.command, until }))
705 $.clock.after(CONFIRM_MS, () => void update($, confirm, x => (x && x.name === name && x.until === until ? null : x)).catch(() => undefined))
706 return `Kill-armed ${name}.`
707}
708
709/** Ends the process the first press of Kill saw on a configuration's port (SIGTERM, then SIGKILL), after which Start works. */
710const kill = async ($: EngineInterface, name: string, armed: KillConfirm): Promise<string> => {
711 const check = await recheck($, armed, { sole: true, released: false })
712 if (!check.ok) {
713 await settle($, armed.port)
714 return `${name}: not killed: ${check.why}.`
715 }
716 const out = await terminate($, armed, { sole: true })
717 if (out.ended && (await read($, adopted))[name]) await forget($, name)
718 await settle($, armed.port)
719 return out.ended ? `Killed process ${armed.pid} on port ${armed.port}.` : `${name}: ${out.why}.`
720}
721
722// ---- reading while the pane is shown ----
723
724// Nothing runs while the pane isn't shown (but the children's own loops): a
725// timer probes the ports every tick while it is, and reads launch.json again
726// every FILE_EVERY.
727const TICK = 2_000
728const FILE_EVERY = 10_000
729let fileAt = 0
730let busy = false
731let shownAt = 0
732let shown = false
733
734const isShown = async ($: EngineInterface): Promise<boolean> => {
735 if (Date.now() - shownAt < 2_000) return shown
736 shownAt = Date.now()
737 try {
738 shown = (await $.ui.panes()).some(p => p.id === PANE && p.isShown && p.isPlaced)
739 } catch {
740 shown = false
741 }
742 return shown
743}
744
745const refresh = async ($: EngineInterface, readFile: boolean) => {
746 if (busy) return
747 busy = true
748 try {
749 if (readFile || !(await read($, file)) || Date.now() - fileAt >= FILE_EVERY) {
750 fileAt = Date.now()
751 const fresh = await loadFile($)
752 const old = await read($, file)
753 if (!old || JSON.stringify({ ...old, at: 0 }) !== JSON.stringify({ ...fresh, at: 0 })) await update($, file, () => fresh)
754 }
755 const configs = (await read($, file))?.configs ?? []
756 await probe($, configs.map(c => c.port))
757 await identify($, configs.map(c => c.port))
758 await sample($, configs)
759 await reconcile($)
760 } finally {
761 busy = false
762 }
763}
764
765const tick = async ($: EngineInterface) => {
766 if (await isShown($)) await refresh($, false)
767}
768
769// ---- the command ----
770
771const USAGE = 'Usage: /launcher (opens the pane), /launcher start <name>, /launcher stop <name>, /launcher restart <name>, /launcher list.'
772
773/** Every configuration on a line: name, state, where. */
774const listing = async ($: EngineInterface) => {
775 const f = await read($, file)
776 if (!f) return 'Reading .claude/launch.json…'
777 if (f.note) return f.note
778 const s = await read($, servers)
779 const lines = f.configs.map(c => {
780 const one = s[c.name]
781 const status = one?.status ?? 'stopped'
782 const port = one?.port ?? c.port
783 const where = status === 'running' || status === 'elsewhere' ? urlOf(port) : `:${port}`
784 return ` ${c.name} ${LOOK[status].word} ${where}`
785 })
786 return [`${f.root}/.claude/launch.json:`, ...(lines.length ? lines : [' (no configurations)'])].join('\n')
787}
788
789const command = async ($: EngineInterface, e: CommandRunInput) => {
790 const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
791 const name = rest.join(' ')
792 await refresh($, true).catch(() => undefined)
793 switch (verb.toLowerCase()) {
794 case '': {
795 // Opened by the person: read now rather than on the next tick.
796 shownAt = 0
797 await $.ui.open({ id: PANE, title: TITLE })
798 return { text: `${TITLE} pane opened.\n${await listing($)}` }
799 }
800 case 'list':
801 case 'status':
802 return { text: await listing($) }
803 case 'start':
804 case 'stop':
805 case 'restart': {
806 if (!name) return { text: `Which one? ${USAGE}` }
807 const v = verb.toLowerCase()
808 return { text: v === 'start' ? await start($, name) : v === 'stop' ? await stop($, name) : await restart($, name) }
809 }
810 default:
811 return { text: USAGE }
812 }
813}
814
815let worker: { cancel: () => void } | undefined
816
817export const register: Register = on => {
818 on('session.start', async ($, e, next) => {
819 for (const name of ['launcher', 'preview']) {
820 await $.command.register({
821 name,
822 description: "Open a pane with this project's dev servers from .claude/launch.json, or start or stop one",
823 argumentHint: '[start|stop|restart <name>]',
824 immediate: true,
825 })
826 }
827 worker?.cancel()
828 worker = $.clock.every(TICK, () => void tick($).catch(() => undefined))
829 void $.ui.open({ id: PANE, title: TITLE })
830
831 return next(e)
832 })
833
834 on('command.run', { command: 'launcher' }, ($, e) => command($, e))
835 on('command.run', { command: 'preview' }, ($, e) => command($, e))
836
837 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
838 // The row itself shows what happened; only a refusal or a failure needs a toast.
839 const quiet = /^(Starting|Stopped|Took over|Kill-armed|Killed)/
840 const say = (what: Promise<string>) => void what.then(text => (quiet.test(text) ? undefined : $.ui.toast(text))).catch(() => undefined)
841 const f = await read($, file)
842 const s = await read($, servers)
843 const o = await read($, open)
844 const armed = await read($, confirm)
845 const columnsOn = await read($, columns)
846 return renderPane(
847 // Buttons keep their press handles across redraws, so a click made just before one still lands.
848 holdButtons($.ui.resolve(e), e) as PaneElements,
849 e.surface,
850 e.props.bodyColumns,
851 f,
852 s,
853 o,
854 {
855 start: name => say(start($, name)),
856 stop: name => say(stop($, name)),
857 restart: name => say(restart($, name)),
858 adopt: name => say(adopt($, name)),
859 kill: name => say(killPress($, name)),
860 toggle: name => void update($, open, o => (o.includes(name) ? o.filter(n => n !== name) : [...o, name])).catch(() => undefined),
861 },
862 {
863 focused: e.props.isFocused,
864 confirm: armed,
865 flow: { on: columnsOn, toggle: () => void update($, columns, was => !was).catch(() => undefined) },
866 },
867 )
868 })
869}
870hooks/view.tsx 506 lines1import type { Elements, RenderElement, RenderSurface } from 'claude-code'
2
3import { basename, bytes, middle } from '@kit/format'
4import { C, GRAY, MUTED, STATUS } from '@kit/tokens'
5import type { StatusKind } from '@kit/tokens'
6import { CELLS, EDGE, MEASURE, SPACE, kit } from '@kit/ui'
7import type { FlowSwitch } from '@kit/ui'
8
9import type { KillConfirm, LaunchConfig, LaunchFile, Server, Status } from '../types'
10
11/** What the pane draws with; the terminal's table has the same elements, Svg aside. */
12export type PaneElements = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Link'> & Partial<Pick<Elements['desktop'], 'Svg'>>
13
14/** What the pane's controls do; the hooks module runs them with its `$`. */
15export type Actions = {
16 start: (name: string) => void
17 stop: (name: string) => void
18 restart: (name: string) => void
19 /** Takes over the server another process runs on its port, so Stop works on it. */
20 adopt: (name: string) => void
21 /** Kill's press: the first arms the confirm, a second within its time ends the process on the port. */
22 kill: (name: string) => void
23 /** Unfolds a configuration's details and output, or folds them. */
24 toggle: (name: string) => void
25}
26
27/**
28 * Each state's word and colour: grey stopped, blue starting, green answering, red
29 * failed; and the kit status that draws it on the terminal (its glyph in the same colour).
30 */
31export const LOOK: Record<Status, { word: string; color: string; kind: StatusKind }> = {
32 stopped: { word: 'stopped', color: MUTED, kind: 'stopped' },
33 starting: { word: 'starting', color: C.blue, kind: 'running' },
34 running: { word: 'running', color: C.ok, kind: 'done' },
35 elsewhere: { word: 'running elsewhere', color: C.ok, kind: 'done' },
36 // Amber: it can't start on its own port until that process ends (or autoPort moves it).
37 taken: { word: 'port taken', color: C.warn, kind: 'warn' },
38 failed: { word: 'failed', color: C.bad, kind: 'failed' },
39}
40
41/** Where a server answers. */
42export const urlOf = (port: number) => `http://localhost:${port}`
43
44/** The command line a configuration runs, as one would type it. */
45export const commandOf = (c: LaunchConfig) => [c.runtimeExecutable, ...c.runtimeArgs].map(a => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')
46
47/** A configuration not yet probed reads as stopped on its own port. */
48const serverOf = (c: LaunchConfig, servers: Record<string, Server>): Server =>
49 servers[c.name] ?? { status: 'stopped', port: c.port, log: [], code: null, startedAt: 0 }
50
51/** How many output lines an unfolded row shows. */
52const TAIL = { terminal: 8, app: 14 } as const
53
54/**
55 * The cells of title a terminal row keeps before its right side gives up parts,
56 * and the header's gaps either side of its rule (which may shrink to nothing).
57 */
58const TITLE_KEEPS = 12
59const RULE_GAPS = 2
60
61/**
62 * A desktop row's measure, in columns, to tell whether its address fits: the band's
63 * inset and its fold arrow (at the row's right end), the gap between parts, and the room a
64 * native Button takes beyond its label.
65 */
66const DESK_LEAD = 4
67const DESK_GAP = 1
68const DESK_BUTTON = 1
69/** The columns a desktop prop's label and the body's indents take, left of a value. */
70const DESK_PROP_LABEL = 14
71
72/** The cells of a running server's CPU sparkline on the terminal: the last 12 samples. */
73const SPARK_CELLS = 6
74/** The CPU percent a sparkline's top stands for at least, so an idle server's noise stays low. */
75const SPARK_FLOOR = 10
76
77/**
78 * A state's word on the terminal, after its glyph: shorter than `LOOK`'s where the
79 * glyph's colour already says it (a server running elsewhere is a green ✓ and "elsewhere").
80 */
81const TERM_WORD: Record<Status, string> = {
82 stopped: 'stopped',
83 starting: 'starting',
84 running: 'running',
85 elsewhere: 'elsewhere',
86 taken: 'taken',
87 failed: 'failed',
88}
89
90/**
91 * What every terminal row's right side gives up as the pane narrows, one step at a
92 * time and the same step for every row, so their columns stay aligned: the address
93 * shortens to its port, then the CPU sparkline goes, then Take over (Kill stays),
94 * then the state's word (the glyph stays), then the address, then Restart (it
95 * stays in `/launcher restart`).
96 */
97type Fit = { address: 'full' | 'port' | null; spark: boolean; word: boolean; restart: boolean; adopt: boolean }
98const FITS: Fit[] = [
99 { address: 'full', spark: true, word: true, restart: true, adopt: true },
100 { address: 'port', spark: true, word: true, restart: true, adopt: true },
101 { address: 'port', spark: false, word: true, restart: true, adopt: true },
102 { address: 'port', spark: false, word: true, restart: true, adopt: false },
103 { address: 'port', spark: false, word: false, restart: true, adopt: false },
104 { address: null, spark: false, word: false, restart: true, adopt: false },
105 { address: null, spark: false, word: false, restart: false, adopt: false },
106]
107
108/**
109 * A server's terminal log as it reads unfolded: without the lines the props above
110 * it already say (the `$ command` this session wrote first, and `exited with code N`
111 * when the exit prop shows it), blank lines trimmed from its ends and runs of them
112 * kept to one, then its last `n` lines.
113 */
114const quietLog = (c: LaunchConfig, s: Server, n: number) => {
115 let lines = s.log.slice()
116 if (lines[0] === `$ ${commandOf(c)}`) lines = lines.slice(1)
117 const exitShown = s.status === 'failed' && s.code !== null
118 const blank = (l: string) => !l.trim()
119 while (lines.length && blank(lines[lines.length - 1]!)) lines.pop()
120 if (exitShown && lines[lines.length - 1] === `exited with code ${s.code}`) lines.pop()
121 lines = lines.filter((l, i) => !blank(l) || (i > 0 && !blank(lines[i - 1]!)))
122 while (lines.length && blank(lines[0]!)) lines.shift()
123 while (lines.length && blank(lines[lines.length - 1]!)) lines.pop()
124 return lines.slice(-n)
125}
126
127/** Notes that mean something went wrong reading the file, rather than there being none. */
128const isError = (note: string) => /^(Unreadable|Could not)/.test(note)
129
130const cells = (s: string) => [...s].length
131
132/** A CPU sample as a figure: a tenth under 10% (an idle server's 0.3%), whole above. */
133const percent = (cpu: number) => `${cpu < 10 ? cpu.toFixed(1).replace(/\.0$/, '') : Math.round(cpu)}%`
134
135/**
136 * What the pane can draw of each server: everything but its CPU and memory as
137 * kept, those as the figures and sparkline show them (the samples the line
138 * spans, its top, the last figure, the memory's). Two equal ones draw alike,
139 * so a refresh that changes neither need not redraw.
140 */
141export const drawnServers = (servers: Record<string, Server>) =>
142 Object.fromEntries(
143 Object.entries(servers).map(([name, { cpu, rss, ...s }]) => [
144 name,
145 {
146 ...s,
147 cpu: cpu ? [cpu.length >= 2, cpu.slice(-2 * SPARK_CELLS).map(percent), percent(Math.max(SPARK_FLOOR, ...cpu))] : null,
148 rss: rss ? bytes(rss) : null,
149 },
150 ]),
151 )
152
153/**
154 * The pane, on every surface: the project as a plain line (on the desktop with how
155 * many answer), then one foldable row per configuration. On the terminal each
156 * row's state, address, CPU and buttons sit in columns shared by every row:
157 *
158 * yahaha/.claude/launch.json
159 *
160 * app ──── ✓ running localhost:5173 ⣀⣤⣶⣤⣀⣀ Stop Restart ▸
161 *
162 * storybook ──── ✕ failed :6006 Start ▾
163 * command npm --prefix app run storybook
164 * exit 1
165 *
166 * > storybook dev -p 6006
167 * Error: …
168 *
169 * `focused` is whether the pane has the keyboard (`e.props.isFocused`). The
170 * launcher binds no keys of its own (its controls are buttons), so it draws no
171 * key hints.
172 */
173export const renderPane = (
174 els: PaneElements,
175 surface: RenderSurface,
176 columns: number,
177 file: LaunchFile | null,
178 servers: Record<string, Server>,
179 open: string[],
180 act: Actions,
181 opts: { focused?: boolean; confirm?: KillConfirm | null; flow?: FlowSwitch } = {},
182): RenderElement => {
183 const { Box, Text, Link } = els
184 const terminal = surface === 'terminal'
185 // On the terminal the rows keep to the measure, so a row's state and controls stay
186 // near its name however wide the pane. The column view's switch is the app surfaces' alone.
187 const k = kit(els, surface, columns, { focused: opts.focused, ...(terminal ? { measure: MEASURE } : { flow: opts.flow }) })
188 if (!file) return terminal ? k.note('reading', 'Reading .claude/launch.json…', 'loading') : <Text dimColor>Reading .claude/launch.json…</Text>
189 if (file.note) {
190 return (
191 <Box flexDirection="column" paddingLeft={terminal ? 0 : EDGE.left} paddingRight={terminal ? 0 : EDGE.right} marginTop={terminal ? 0 : 1}>
192 {terminal ? (
193 k.note('note', file.note, isError(file.note) ? 'error' : 'empty')
194 ) : (
195 <Text dimColor wrap="truncate">
196 {file.note}
197 </Text>
198 )}
199 </Box>
200 )
201 }
202
203 const all = file.configs.map(c => ({ c, s: serverOf(c, servers) }))
204 // A row's width on the desktop: the pane's, or a flow column's when the pane is wide enough for columns.
205 const lane = k.flowColumns(all.length).width
206 const answering = all.filter(x => x.s.status === 'running' || x.s.status === 'elsewhere').length
207 const failed = all.filter(x => x.s.status === 'failed').length
208
209 const isUp = (s: Server) => s.status === 'running' || s.status === 'elsewhere'
210 /**
211 * What can be done to a server: Start while it's stopped or failed; Stop and the
212 * quiet Restart while it runs from here (started or taken over); Take over and
213 * Kill while its own server runs elsewhere; Kill (and Start on the next port,
214 * with autoPort) while another process holds its port. Kill asks once more
215 * (`Kill PID 1234?`, red) before it ends anything.
216 */
217 const controls = (c: LaunchConfig, s: Server) => {
218 const mine = s.status === 'running' || s.status === 'starting'
219 const canStart = s.status === 'stopped' || s.status === 'failed' || (s.status === 'taken' && Boolean(c.autoPort))
220 const held = (s.status === 'elsewhere' || s.status === 'taken') && Boolean(s.pid)
221 const armed = Boolean(held && opts.confirm && opts.confirm.name === c.name && opts.confirm.pid === s.pid)
222 return {
223 start: canStart ? { key: `start-${c.name}`, label: 'Start', onPress: () => act.start(c.name) } : null,
224 stop: mine ? { key: `stop-${c.name}`, label: 'Stop', onPress: () => act.stop(c.name) } : null,
225 restart: mine ? { key: `restart-${c.name}`, label: 'Restart', quiet: true, onPress: () => act.restart(c.name) } : null,
226 adopt: held && s.status === 'elsewhere' ? { key: `adopt-${c.name}`, label: 'Take over', quiet: true, onPress: () => act.adopt(c.name) } : null,
227 kill: held
228 ? armed
229 ? { key: `kill-${c.name}`, label: `Kill PID ${s.pid}?`, tone: 'danger' as const, onPress: () => act.kill(c.name) }
230 : { key: `kill-${c.name}`, label: 'Kill', quiet: true, onPress: () => act.kill(c.name) }
231 : null,
232 }
233 }
234 /** A row's actions in order: the main ones, then the quiet ones, less Restart or Take over where the row has no room for them. */
235 const listOf = (a: ReturnType<typeof controls>, fit: { restart: boolean; adopt: boolean }) => [
236 a.start,
237 a.stop,
238 fit.restart ? a.restart : null,
239 fit.adopt ? a.adopt : null,
240 a.kill,
241 ]
242 /** Where a server answers on a terminal row, whole; one that doesn't answer shows its port. */
243 const addressOf = (s: Server) => (isUp(s) ? `localhost:${s.port}` : `:${s.port}`)
244 /** A server's CPU series once there's a line to draw: answering, two samples or more. */
245 const sparkOf = (s: Server) => (isUp(s) && s.cpu && s.cpu.length >= 2 ? s.cpu : null)
246
247 /**
248 * The terminal rows' shared columns: each slot's cells (the widest row's) and the
249 * one `Fit` that leaves every title its first `TITLE_KEEPS` cells and a rule.
250 */
251 const grid = (() => {
252 if (!terminal) return null
253 const max = (ns: number[]) => Math.max(0, ...ns)
254 const word = max(all.map(({ s }) => cells(TERM_WORD[s.status])))
255 const full = max(all.map(({ s }) => cells(addressOf(s))))
256 const port = max(all.map(({ s }) => cells(`:${s.port}`)))
257 const spark = all.some(({ s }) => sparkOf(s) !== null)
258 const labels = (c: LaunchConfig, s: Server, fit: Fit) =>
259 listOf(controls(c, s), fit)
260 .filter(x => x !== null)
261 .map(x => x.label)
262 const actions = (fit: Fit) =>
263 max(all.map(({ c, s }) => {
264 const l = labels(c, s, fit)
265 return l.reduce((n, x) => n + cells(x), 0) + CELLS.group * Math.max(0, l.length - 1)
266 }))
267 const state = (fit: Fit) => 1 + (fit.word ? CELLS.item + word : 0)
268 const total = (fit: Fit) => {
269 const slots = [
270 state(fit),
271 fit.address ? (fit.address === 'full' ? full : port) : 0,
272 fit.spark && spark ? SPARK_CELLS : 0,
273 actions(fit),
274 ].filter(n => n > 0)
275 return slots.reduce((a, b) => a + b, 0) + CELLS.group * (slots.length - 1)
276 }
277 const title = Math.min(max(all.map(({ c }) => cells(c.name))), TITLE_KEEPS)
278 const room = k.width - title - RULE_GAPS
279 const fit = FITS.find(f => total(f) <= room) ?? FITS[FITS.length - 1]!
280 return {
281 fit,
282 state: state(fit),
283 address: fit.address === 'full' ? full : port,
284 spark: fit.spark && spark,
285 actions,
286 }
287 })()
288
289 /**
290 * One row's right side: its state, where it answers or its port, then what can be
291 * done to it. On the terminal, slots aligned across the rows (`grid`); the section
292 * (`keepRight`) keeps them whole and cuts the title to what's left.
293 */
294 const row = (c: LaunchConfig, s: Server) => {
295 const look = LOOK[s.status]
296 const up = isUp(s)
297 const acts = controls(c, s)
298
299 const where = (label: string) =>
300 up ? (
301 <Link key={`url-${c.name}`} href={urlOf(s.port)} label={label}>
302 <Text {...k.ink(C.blue)} wrap="truncate">
303 {label}
304 </Text>
305 </Link>
306 ) : (
307 <Text key="port" {...(terminal ? { dimColor: true } : { color: GRAY.mid })} wrap="truncate">{`:${s.port}`}</Text>
308 )
309
310 if (terminal && grid) {
311 // [glyph word] [address] [sparkline] [actions], each slot as wide as the
312 // widest row's, so the rows' states, addresses and buttons line up.
313 const status = STATUS[look.kind]
314 const fit = grid.fit
315 const address = fit.address === 'full' ? addressOf(s) : `:${s.port}`
316 const cpu = sparkOf(s)
317 const shown = listOf(acts, fit)
318 const right = (
319 <Box key={`right-${c.name}`} flexDirection="row" gap={CELLS.group}>
320 <Box key={`state-${c.name}`} flexDirection="row" gap={CELLS.item} width={grid.state} flexShrink={0}>
321 <Text {...k.ink(status.color)}>{status.glyph}</Text>
322 {fit.word ? (
323 <Box key={`word-${c.name}`} flexDirection="row" minWidth={0}>
324 <Text {...k.ink(status.color)} wrap="truncate">
325 {TERM_WORD[s.status]}
326 </Text>
327 </Box>
328 ) : null}
329 </Box>
330 {fit.address ? (
331 <Box key={`where-${c.name}`} flexDirection="row" width={grid.address} flexShrink={0}>
332 {up ? (
333 <Link key={`url-${c.name}`} href={urlOf(s.port)} label={address}>
334 <Text wrap="truncate">
335 {address}
336 </Text>
337 </Link>
338 ) : (
339 <Text dimColor wrap="truncate">
340 {address}
341 </Text>
342 )}
343 </Box>
344 ) : null}
345 {grid.spark ? (
346 <Box key={`cpu-${c.name}`} flexDirection="row" width={SPARK_CELLS} flexShrink={0}>
347 {cpu ? (
348 <Box key={`spark-${c.name}`} flexDirection="row">
349 {k.spark(cpu, { width: SPARK_CELLS, max: Math.max(SPARK_FLOOR, ...cpu) })}
350 </Box>
351 ) : null}
352 </Box>
353 ) : null}
354 {grid.actions(fit) ? (
355 <Box key={`do-${c.name}`} flexDirection="row" width={grid.actions(fit)} flexShrink={0}>
356 {shown.some(Boolean) ? k.actions(`actions-${c.name}`, shown) : null}
357 </Box>
358 ) : null}
359 </Box>
360 )
361 return { title: c.name, right }
362 }
363
364 // The state's word keeps its width; the address gives way first, and drops
365 // out when the row can't hold it beside the title, the word and the actions.
366 const shown = listOf(acts, { restart: true, adopt: true }).filter(x => x !== null)
367 const address = up ? `localhost:${s.port}` : `:${s.port}`
368 const need =
369 DESK_LEAD + cells(c.name) + DESK_GAP + cells(look.word) + DESK_GAP + cells(address) + shown.reduce((n, a) => n + DESK_GAP + cells(a.label) + DESK_BUTTON, 0)
370 const right = (
371 <Box key="right" flexDirection="row" alignItems="center" gap={SPACE.item} minWidth={0} flexShrink={1}>
372 <Box key={`state-${c.name}`} flexShrink={0}>
373 <Text key="state" color={look.color}>
374 {look.word}
375 </Text>
376 </Box>
377 {need <= lane ? (
378 <Box key={`where-${c.name}`} flexDirection="row" minWidth={0} flexShrink={1}>
379 {where(address)}
380 </Box>
381 ) : null}
382 {shown.length ? k.actions(`actions-${c.name}`, shown) : null}
383 </Box>
384 )
385 return { title: c.name, right }
386 }
387
388 /** Unfolded: what it runs, where, how it ended, then the tail of its output. */
389 const details = (c: LaunchConfig, s: Server) => {
390 const tail = terminal ? quietLog(c, s, TAIL.terminal) : s.log.slice(-TAIL.app)
391 const held = s.status === 'elsewhere' || s.status === 'taken'
392 const note = s.adopted
393 ? `Taken over: its output can't be shown here, as it writes to wherever it was started, not to the launcher.`
394 : s.status === 'elsewhere'
395 ? `Port ${s.port} answers, but this session didn't start it.`
396 : s.status === 'taken'
397 ? `Port ${s.port} is held by another process, not this configuration's server.`
398 : null
399 const line = (key: string, text: string) =>
400 terminal ? (
401 k.note(key, text)
402 ) : (
403 <Text key={key} dimColor wrap="truncate">
404 {text}
405 </Text>
406 )
407 // The process holding the port, cut in the middle so its start and end both show.
408 const proc = (held || s.adopted) && s.command ? s.command : null
409 return [
410 k.prop(`command-${c.name}`, 'command', commandOf(c)),
411 c.cwd ? k.prop(`cwd-${c.name}`, 'cwd', c.cwd) : null,
412 // On the terminal, what the header's sparkline last measured, as figures.
413 terminal && s.cpu?.length ? k.prop(`cpu-${c.name}`, 'cpu', percent(s.cpu[s.cpu.length - 1]!)) : null,
414 terminal && s.cpu?.length && s.rss ? k.prop(`memory-${c.name}`, 'memory', `${bytes(s.rss)}B`) : null,
415 (held || s.adopted) && s.pid ? k.prop(`pid-${c.name}`, 'pid', String(s.pid)) : null,
416 proc
417 ? terminal
418 ? k.prop(`process-${c.name}`, 'process', proc, { path: true })
419 : k.prop(`process-${c.name}`, 'process', middle(proc, Math.max(16, lane - DESK_PROP_LABEL)))
420 : null,
421 note ? line(`note-${c.name}`, note) : null,
422 s.status === 'failed' && s.code !== null ? k.prop(`exit-${c.name}`, 'exit', <Text {...k.ink(C.bad)}>{String(s.code)}</Text>) : null,
423 // A server running elsewhere writes its output there: no empty log to show.
424 !tail.length && (held || s.adopted) ? null : tail.length ? (
425 // The output is its own block: one blank row under the props.
426 <Box key={`log-${c.name}`} flexDirection="column" marginTop={terminal ? k.space.block : SPACE.block} minWidth={0}>
427 {tail.map((line, i) =>
428 terminal ? (
429 k.note(`log-${c.name}-${i}`, line || ' ')
430 ) : (
431 <Text key={`log-${c.name}-${i}`} dimColor wrap="truncate">
432 {line || ' '}
433 </Text>
434 ),
435 )}
436 </Box>
437 ) : terminal ? (
438 <Box key={`log-${c.name}`} flexDirection="column" marginTop={k.space.block}>
439 {k.note(`log-${c.name}-none`, 'No output yet.')}
440 </Box>
441 ) : (
442 <Text key={`log-${c.name}`} dimColor>
443 No output yet.
444 </Text>
445 ),
446 ]
447 }
448
449 const rows: RenderElement[] = all.map(({ c, s }) =>
450 k.section({
451 id: c.name,
452 ...row(c, s),
453 keepRight: true,
454 collapsed: !open.includes(c.name),
455 onToggle: () => act.toggle(c.name),
456 children: details(c, s),
457 }),
458 )
459
460 const path = `${basename(file.root) || file.root}/.claude/launch.json`
461 const counts = k.summary([
462 [String(answering), 'running', answering ? C.ok : undefined],
463 failed ? [String(failed), 'failed', C.bad] : null,
464 ])
465 const empty = 'No configurations in .claude/launch.json.'
466
467 if (terminal) {
468 // The file's path as a dim head line (no counts: every row's header says its
469 // state), one blank row, then the sections from the pane's left edge. The path
470 // ends where the rows' rules do.
471 return (
472 <Box flexDirection="column">
473 <Box key="top" flexDirection="column" marginBottom={1}>
474 {k.item('head', { text: path, dim: true, path: true, width: k.edge.left + k.width })}
475 </Box>
476 {rows.length ? rows : k.note('empty', empty)}
477 </Box>
478 )
479 }
480
481 const head = (
482 <Box key="head" flexDirection="row" justifyContent="space-between" gap={SPACE.item} width="100%">
483 <Box flexShrink={1} minWidth={0}>
484 <Text color={GRAY.mid} wrap="truncate">
485 {path}
486 </Text>
487 </Box>
488 {counts}
489 </Box>
490 )
491
492 return (
493 // The sections run the pane's full width, as the overview's do; the plain line sits on the body's edges.
494 <Box flexDirection="column" marginTop={1}>
495 <Box key="top" flexDirection="column" paddingLeft={EDGE.left} paddingRight={EDGE.right}>
496 {head}
497 {rows.length ? null : <Text dimColor>{empty}</Text>}
498 </Box>
499 {/* In columns when the pane is wide; one stack at a sidebar's width. */}
500 {k.flow('sections', rows)}
501 {/* The column view's switch, on a pane wide enough for columns. */}
502 {k.footer()}
503 </Box>
504 )
505}
506types/index.d.ts 108 lines1/** One entry of `.claude/launch.json`'s `configurations`, as the desktop app reads it. */
2export type LaunchConfig = {
3 name: string
4 runtimeExecutable: string
5 runtimeArgs: string[]
6 port: number
7 /** Relative to the project root (the folder holding `.claude/`); absent, the root. */
8 cwd?: string
9 env?: Record<string, string>
10 /** When the port is taken, run on the next free one instead. */
11 autoPort?: boolean
12}
13
14/** The project's `.claude/launch.json`, as last read. */
15export type LaunchFile = {
16 /** The folder holding `.claude/launch.json`; empty when none was found. */
17 root: string
18 configs: LaunchConfig[]
19 /** Why there's nothing to show (no file, unreadable JSON). */
20 note?: string
21 /** When it was read (ms since epoch). */
22 at: number
23}
24
25/**
26 * - `stopped`: nothing answers on its port;
27 * - `starting`: started from this session, its port not answering yet;
28 * - `running`: started from this session (or taken over) and answering;
29 * - `elsewhere`: something this session didn't start answers on its port, and
30 * it is this configuration's server (its command line or working directory
31 * match), or there's no telling whose it is;
32 * - `taken`: the process on its port is another one's (another configuration's
33 * on the same port, or another program);
34 * - `failed`: started from this session and exited on its own with an error.
35 */
36export type Status = 'stopped' | 'starting' | 'running' | 'elsewhere' | 'taken' | 'failed'
37
38/** A server this session didn't start but took over: Stop signals its process. */
39export type Adopted = {
40 pid: number
41 port: number
42 /**
43 * When it started and its command line, as ps said when it was taken over:
44 * Stop signals the pid only while ps still says both (not a later process
45 * given the same pid).
46 */
47 start: string
48 command: string
49 /** When it was taken over (ms since epoch). */
50 at: number
51}
52
53/** A Kill pressed once, waiting for the second press that confirms it. */
54export type KillConfirm = {
55 name: string
56 /** The process the first press found on `port`, and who it was (as in Adopted): the second press signals it only if it's still that. */
57 pid: number
58 port: number
59 start: string
60 command: string
61 /** When the confirm lapses (ms since epoch, the engine's clock). */
62 until: number
63}
64
65/** One configuration's server as the pane draws it. */
66export type Server = {
67 status: Status
68 /** The port it runs on: the configuration's, or the free one `autoPort` picked. */
69 port: number
70 /** The last lines it wrote, oldest first (at most 200). */
71 log: string[]
72 /** How it last ended: its exit code, null for a signal or while it runs. */
73 code: number | null
74 /** When this session started it (ms since epoch); 0 when it didn't. */
75 startedAt: number
76 /**
77 * While it answers: the CPU (percent of one core) of the process listening on
78 * its port, one sample a tick while the pane is shown, oldest first (at most 60).
79 * Absent when it can't be measured (no lsof or ps).
80 */
81 cpu?: number[]
82 /** While it answers: that process's resident memory, in bytes, at the last sample. */
83 rss?: number
84 /** The process listening on its port when this session didn't start it (lsof), and its command line (ps). */
85 pid?: number
86 command?: string
87 /** Running because it was taken over: its output isn't the launcher's to show. */
88 adopted?: boolean
89}
90
91declare module 'claude-code' {
92 interface PluginState {
93 launcher: {
94 file: LaunchFile | null
95 /** By configuration name. */
96 servers: Record<string, Server>
97 /** Configurations whose details and output are unfolded, by name. */
98 open: string[]
99 /** Servers taken over from elsewhere, by configuration name. */
100 adopted: Record<string, Adopted>
101 /** The row whose Kill waits for its confirming press, if any. */
102 confirm: KillConfirm | null
103 /** Whether a wide app pane flows its rows into columns (the footer's switch); on until flipped. */
104 columns: boolean
105 }
106 }
107}
108