SLOPSHOPPER

launcher

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

newpanecommandtimer
v0.2.5MITupdated 2026-10-14satoramoto/awesome-claude/mods/launcher
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · launcher
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /launcher ⎿ launcher: Launcher pane opened. ⎿ launcher: No .claude/launch.json in this project. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

awesome-claude

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.

The mods

ModWhat it showsOpens withNeeds
overviewthe current session at a glance: context, limits, activity, agents, the branches it started and their pull requests, coston its own when a session starts; /overview opens it againgit for Workspaces; gh signed in for pull requests
issuesthe open GitHub issues of the session's repository, newest change first, with actions on eachon its own when a session starts; /issues opens it againa GitHub repository; gh signed in
launcherthe 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 outputon 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
labfor 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.jsonthe index on its own when a session starts; /lab, /lab <id>, /lab gallerynothing

Forked mods

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.

ModWhat it doesOriginalLicense
filetreean 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 itdata-goblin/claude-code-filetree, by Kurt BuhlerMIT
blast-radiusholds a risky shell command (rm -rf, git reset --hard, git clean, force push, migrations) and shows what it would change, with Proceed and Cancelanthropics/claude-code-playground, by AnthropicApache-2.0

Requirements

  • Claude Code: tested on 2.1.296.
  • git, for overview's Workspaces section.
  • The GitHub CLI, signed in (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, only to build from source (bun install, then bun build.ts). Installing from the marketplace needs nothing else.

What the panes do

/overview

Opens by itself when a session starts; /overview brings it back after you close it. Its sections, each folding open and shut:

  • Context: how full the context window is, by kind, and the room left before compaction.
  • Limits: your plan's usage limits and when they reset.
  • Activity: tool calls per turn, sliced by tool, with failures.
  • Agents: the session's subagents, each with its status, tokens and time.
  • Workspaces: the branches the session started, each with its pull request's state, checks and lines changed. Pull requests are read with gh every 30 seconds while any is open.
  • Accounting: the session's cost and model.

A footer shows the session's share of the machine's cpu, memory and network.

/issues

Opens by itself when a session starts; /issues brings it back. Lists the open issues of the session's repository (read with gh issue list).

  • Quick add creates an issue from a title alone, with an empty body (gh issue create).
  • Refine, Start and Sprint send a prompt to the session, as if you'd typed it, and the session does the work with gh:
  • Refine: the session reads the issue, asks you about what's unclear, rewrites its body and labels it refined.
  • Start: one subagent takes the issue in its own worktree and branch off the default branch, and reports back when it's pushed. An issue not yet labelled refined asks first whether to refine it.
  • Sprint (select several issues): one subagent per issue in parallel, each in its own worktree, integrated on one 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.

/launcher

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

  • States: stopped (grey); starting (blue: started here, its port not answering yet); running (green, with 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).
  • Start runs 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.
  • Unfold a row for its command, its exit code, and the tail of its output (the last 200 lines are kept, colours stripped).
  • From the prompt: /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.
  • Which ports answer comes from 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.
  • Stop frees the port: checked with 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.

Install

From the marketplace

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.

Getting updates

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" }] }
  ]
}

Try it from a clone, for one session

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

From source, while changing the mods

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.

Making a mod

AGENTS.md lists the commands and checks. The project's skills, which Claude Code loads when working in this repository, hold the how-to:

FolderWhat 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.tswhere 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.jsonthe 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.

The playground

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.

Storybook

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.

Why a compiler

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.

Drawing for the desktop

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.

License

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

Source 3 files
hooks/register.tsx 870 lines
1import { 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}
870
hooks/view.tsx 506 lines
1import 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}
506
types/index.d.ts 108 lines
1/** 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