A pane of the project's dev servers: start, restart and watch them, and fill a crash's error into the prompt.

Small TypeScript mods that live inside Claude Code: a pane that knows your next step, one-click replies, a rate-limit countdown that resumes for you, your repo's pull requests and issues beside the conversation, a shelf of paths you use every day, presets that switch the model and the effort with one press, a chime when a long turn ends, a guard for the Office file you left open, a question before a commit or push on the default branch, a question before Claude reads a
.envor key file, a list of the files this session made, a list of the ones it read, a check that keeps banned claims out of a pull request, a gate that runs your checks and hands back only the failures, a nudge to fetch fresh tab IDs when a browser tab is gone, a name for the session from its first prompt, your project's dev servers in a pane that fills a crash's error into the prompt, and one dialog for every mod's settings.
claude-mods is one developer's personal toolbox of Claude Code mods, shared as a plugin marketplace so anyone can install them. The mods are built for the author's own workflow first, and you are a welcome guest: install one, install all twenty-one, or read the source and write your own.
A mod is a plugin of function hooks: TypeScript that runs inside Claude Code and changes what it shows (a pane in the side panel, a band of buttons above the prompt, the status line) or what it does between turns. The mod's own code decides when to act, even when what it does is send the model a prompt.
| Runs as | Who decides when it acts | |
|---|---|---|
| Mod | TypeScript function hooks inside Claude Code | The mod's own code |
| Skill | Instructions the model reads | The model |
| Plain plugin | Commands, agents or shell hooks | You, or a shell script |
| Mod | Where it shows | What it does |
|---|---|---|
| 🧭 whats-next | Pane in the side panel | Lists the next steps of your workflow, each with a prompt ready to paste |
| ⚡ quick-reply | Band above the prompt | One-click replies, including the options Claude just offered, Pass, Fail and Skip for a verdict, and the next wayfinder ticket |
| ⏳ auto-resume | Band and status line | Counts down to a rate limit's reset, then sends "continue" |
| 🐙 github-panel | Pane in the side panel | The repo's open pull requests and issues, one click from the browser or from /implement and /wayfinder in the prompt; the issue a /wayfinder run works on, pinned with its next ticket; a toast when your branch's checks turn green or red |
| 🏛️ archon-panel | Pane in the side panel | This project's Archon workflow runs, their graphs and logs, and the approvals that wait on you, answered from the pane |
| 📚 shelf | Band above the prompt | Named folders and files; one click drops a path into what you are typing |
| 🔔 turn-chime | Sound and toast | Tells you when a long turn ends or Claude stops to ask you something |
| 🔒 open-file-guard | Question dialog | Asks you to close a Word, Excel or PowerPoint file before Claude uses it |
| 🌿 branch-guard | Question dialog | Asks you before Claude commits or pushes on the default branch |
| 🔑 env-guard | Question dialog | Asks you before Claude reads a .env or key file, and refuses when no one is there to answer |
| 🩹 bash-quoting-rescue | Refused tool call | Stops a shell command that does not parse, such as an unclosed quote, before any of it runs |
| 📂 outputs | Pane in the side panel | The files this session made or changed, newest first; click one to open it |
| 🔎 sources | Pane in the side panel | The files Claude read, grouped by where they came from, with a lock to the project folder |
| 📊 hud | Two lines under the prompt | Model, effort, context window, rate limits, turn timer, tool calls, agents, git state, worktree, cost, session length and folder, each named and in colour |
| 🧹 post-merge-cleanup | Question dialog and toast | After a PR merges, /cleanup switches to the default branch, pulls and deletes the branch |
| 🧾 pre-pr-claims-check | Refusal Claude reads | Refuses gh pr create and gh pr edit while the pull request cites a file and line, holds a placeholder, or spells out a count |
| 🚦 lint-test-gate | Band above the prompt | Runs your checks on a press and before Claude's git commit, and hands back only the failures |
| 🔄 chrome-tab-self-heal | Note after a browser tool's error | When a Claude in Chrome tab is gone, tells Claude to fetch the current tab IDs before it tries again |
| 🏷 session-auto-namer | Band above the prompt | Suggests a name for the session from its first prompt; one press renames it |
| 🧠 model-effort-presets | Band above the prompt | Plan and execute presets: one press switches the model and the effort together |
| 🖥 dev-server-manager | Pane in the side panel, status line and toast | Starts, restarts and watches the project's dev servers; a crash toasts, restarts and fills its error into the prompt |
| ⚙ mod-settings | Gear on each pane; a dialog | Change and save any mod's settings without leaving the session |
When more than one mod has a pane open, Claude Code shows them as tabs in the side panel.
A pane listing the next steps of your workflow for the project folder, kept between sessions. A skill of your choosing answers "what's next" in a headless run beside your session, and the mod turns the answer into steps.
What's next refresh
updated 3 min ago
Fix the failing parse test
working on it done
The check is red, so nothing else can merge.
Open a pull request for the fix
Review comes before the next feature starts.
Triage the two new issues
They arrived while you were heads-down.
flowchart LR
A[Skill answers<br/>what's next] --> B[Steps in the pane]
B --> C[You read a step's prompt<br/>and send it yourself]
C --> D[Step glows:<br/>working on it]
D --> E{Laya, Jev or Haiku:<br/>is the step finished?}
E -- not yet --> D
E -- yes --> F[Step leaves the list]
d to drop it yourself.git push, git config, git -c, gh api and --output are always denied.| Command or key | What it does |
|---|---|
/whats-next | Bring the pane to the front |
/whats-next refresh | Ask the skill again |
r | Refresh |
1 to 9 | Show that step's prompt in the pane |
p, n, c | Paste the shown prompt, paste it after /clear (and the prime command, once its turn ends), or copy it |
b | Back to the list |
d | Mark the active step done |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Skill | skill | /ask-sean | The skill that answers "what's next", written as you would run it: / followed by letters, digits, _, :, . or - |
| Most steps | maxSteps | 5 | How many steps to ask for (1-9) |
| Refresh on start | refreshOnStart | on | Ask for a fresh list when a session starts in a git repository |
| Tools the headless run may use | allowedTools | empty: the read-only set | Comma-separated permission rules for the headless run |
| Model | model | empty: your default | Model for the headless run, as an alias (haiku) or a full id |
| Prime command | primeCommand | empty: none | A slash command, with any arguments, run after /clear and before the paste (/lril:prime); the prompt is filled once the turn it starts ends, finished or not. A value that is not one slash command on one line is never run, and the step view says so |
| System One models | modelChoice | local only | Which System One model judges whether a turn finished the active step: local only (Laya on this machine, or Haiku as before), local first (Laya, and TypeSafe's hosted Jev while Laya is unavailable) or hosted first (Jev, and Laya while Jev is unavailable) |
| Jev API key | jevApiKey | empty | Your key for Jev, from console.typesafe.ai, kept in secure storage. Sent only to https://api.typesafe.ai, and only while System One models allows Jev |
| Laya port | layaPort | 8000 | The port your laya-serve listens on at 127.0.0.1 (1-65535) |
/ask-sean is the author's own skill, so point the Skill setting at a skill of yours that answers "what should I do next?" (how to set it):
echo '{"skill": "/next-steps", "maxSteps": "3", "model": "haiku"}' | claude plugin configure whats-next@claude-mods --values-stdin
The read-only set, used while Tools is empty, is Bash(git status:*), Bash(git log:*), Bash(git diff:*), Bash(git show:*), Bash(git rev-parse:*), Bash(git branch --show-current), Bash(git branch -vv), Bash(git remote -v), Bash(gh issue list:*), Bash(gh issue view:*), Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr checks:*), Bash(gh run list:*), Read, Glob and Grep.
(...). One rule that is not, such as one starting with -, discards your whole list and the read-only set is used.Skill, so a skill that calls another still works. MCP servers load only when a rule names an mcp__ tool.What the judge sends to a System One model, once after each answered turn while a step is active:
Needs: the claude CLI on the PATH, and the skill named in the Skill setting. Laya and a Jev key are optional: with neither, the judge is Haiku, as before.
One row of buttons above the prompt after each answer. When Claude ends on a question, the band offers the choices Claude asked you to pick from, then your replies to a question. After any other answer it offers your other replies. You set both lists in the settings below.
Reply: [a: Keep the copies] [b: Add a sync script] [Yes] [Go with your recommendation] [No]
Verdicts. When Claude asks for a pass/fail verdict on a test or a check, as a UAT or /gsd:verify-work step does ("Pass or fail?", "Did it pass?", "Type pass or describe what's wrong"), the band offers a verdict in place of your replies:
Reply: [Pass] [Fail…] [Skip]
Pass sends "pass" and Skip sends "skip", as your own message. Fail… sends nothing: it puts "Fail: " in the prompt, where you say what went wrong and can paste a screenshot. A test's numbered steps are not offered as choices, and a question that only mentions passing ("Shall I make the tests pass?") gets your usual replies.
Next ticket. When you work a map with the wayfinder skill, the band leads with the next ticket after each turn that closes one or charts the map:
Reply: [Next ticket: /wayfinder 135] [Continue] [Commit and push]
One press runs /clear and then /wayfinder 135, the loop you would otherwise type. It is offered only in a session that ran the wayfinder skill, after a turn whose gh issue close worked; after charting it names the map the turn created. A close made some other way (gh api, the web) is not seen, so no button shows, and once a turn closes the map itself the loop ends.
| Setting | Key | Default | Meaning | |||
|---|---|---|---|---|---|---|
| Replies to a question | questionReplies | `Yes\ | Go with your recommendation\ | No` | Shown after Claude asks something, separated by `\ | `; empty shows only the choices Claude offered |
| Replies otherwise | idleReplies | `Continue\ | Commit and push` | Shown after any other answer; empty hides the band then, except for Next ticket | ||
| Offer the next wayfinder ticket | wayfinderNext | true | After a wayfinder turn closes a ticket or charts a map, offer Next ticket | |||
| System One models | modelChoice | local only | Which System One model reads how each answer ends: local only (Laya on this machine, or the band's own reading as before), local first (Laya, and TypeSafe's hosted Jev while Laya is unavailable) or hosted first (Jev, and Laya while Jev is unavailable) | |||
| Jev API key | jevApiKey | empty | Your key for Jev, from console.typesafe.ai, kept in secure storage. Sent only to https://api.typesafe.ai, and only while System One models allows Jev | |||
| Laya port | layaPort | 8000 | The port your laya-serve listens on at 127.0.0.1 (1-65535) |
Each list holds up to six replies. A reply longer than 120 characters is cut short, and a repeat is dropped. For example, to answer questions with your own three replies and hide the band after other answers (how to set it):
echo '{"questionReplies": "Yes|No|Explain that first", "idleReplies": ""}' | claude plugin configure quick-reply@claude-mods --values-stdin
What the band sends to a System One model, once after each answered turn:
Laya and a Jev key are optional: with neither, the band reads each answer as before.
When a turn dies on a rate limit or an overloaded API, auto-resume counts down to the reset and sends "continue" for you. Go to lunch, and come back to finished work.
⏳ rate limited: sending "continue" in 1 h 12 min [Resume now] [Cancel]
| Command | What it does |
|---|---|
/auto-resume | Say what is waiting, if anything |
/auto-resume now | Send the resume now |
/auto-resume cancel | Cancel the wait |
/auto-resume in <minutes> | Schedule a resume yourself (1 to 1440) |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Resume prompt | text | continue | What is sent when the wait is over; empty sends continue |
| Grace after reset (s) | graceSeconds | 60 | Extra seconds to wait past the limit's reset time (0-900) |
| Retry overloaded/server errors | retryOverloaded | on | Also resume after an overloaded or server error, backing off from one minute |
| Most retries in a row | maxRetries | 5 | Give up after this many resumes without a successful answer (1-20) |
For example, to send a longer prompt and wait two minutes past the reset (how to set it):
echo '{"text": "continue where you left off", "graceSeconds": "120"}' | claude plugin configure auto-resume@claude-mods --values-stdin
A GitHub pane beside What's next listing the repo's open pull requests and issues. Click one to open it in the browser. Under each issue, implement and wayfinder put /implement or /wayfinder and the issue's URL in the prompt. Nothing is sent. While a /wayfinder effort is under way, its issue is pinned at the top, with the next ticket to take.
octocat/hello-world refresh
updated just now
Decide how shared code is copied unpin
3 done · 1 takeable · 1 claimed · 2 blocked
next: Pick the drift check decision
work next
Pull requests 2 all
#41 Add a drift check for copied guards
draft · @octocat
#40 Bring the asked pane to the front
✗ checks failing · @hubot fix
Issues 2 all
#39 Share the headless-session check
blocked by #12 · ready… implement wayfinder
#12 Decide how shared code is copied
needs-triage · @hubot implement wayfinder
implement and wayfinder buttons take a line of their own.r.fix button. It fills the prompt box with the failed checks' names, the last 40 lines of the failed run's log, and "Fix it." Nothing is sent: you read it and send it yourself. The log is fetched only when you press fix; when gh cannot fetch it, such as while the run is still going, the prompt holds the names alone./wayfinder on an issue of this repo (/wayfinder 12, /wayfinder #12 or the issue's URL) pins that issue and brings the pane to the front. Anything else, such as prose or another repo's issue, pins nothing. The pin is kept for the repo across sessions, one at a time: a run on another issue replaces it. It goes when you press unpin, or once the issue is closed.next is the first takeable ticket in the order the issue lists its sub-issues, with its wayfinder: type label; click it to open it on GitHub. With nothing takeable it says so; an issue with no sub-issues yet says no tickets yet.work next fills /wayfinder and the pinned issue's URL into the prompt, without sending it, so the skill takes the frontier ticket fresh when it runs.| Command or key | What it does |
|---|---|
/github | Bring the pane to the front and refresh it |
r | Refresh |
all | Open the whole list on GitHub |
implement, wayfinder | Fill the command and the issue's URL into the prompt, without sending it |
fix | Fill a request to fix the current branch's failing checks into the prompt box |
unpin | Drop the pinned issue |
work next | Fill /wayfinder and the pinned issue's URL into the prompt, without sending it |
| Setting | Key | Default | Meaning |
|---|---|---|---|
| Most items per list | limit | 30 | How many open pull requests and issues to list each (1-100) |
| Refresh every (minutes) | refreshMinutes | 5 | How often to refresh (0-120); 0 refreshes only on open, after turns and on r |
| Implement button fills | implementCommand | /implement | The slash command the implement button fills before the issue's URL; empty hides the button |
| Wayfinder button fills | wayfinderCommand | /wayfinder | The slash command the wayfinder and work next buttons fill before the issue's URL, and the skill whose runs pin an issue; empty hides the button and turns pinning off |
A command must start with / and hold no spaces. Any other value falls back to the default, and a message says so when the session starts. A button is labelled with its command, without the /.
For example, to list fifty of each and stop the timer (how to set it):
echo '{"limit": "50", "refreshMinutes": "0"}' | claude plugin configure github-panel@claude-mods --values-stdin
Needs: the GitHub CLI, logged in, and a folder with a GitHub remote.
An Archon pane in the side panel: this project's Archon workflow runs, their graphs and logs, and the approvals that wait on you. A run that needs you comes first, and the pane answers its approval, or resumes or abandons it, without leaving the session.
1: Runs 3 ⏸1 2: Graph 3: Log 4: Archon's log ↻ ⚙️
+2 live in other projects
⏸ archon-interactive-prd needs your approval 4m
Draft the PRD for the widget pane
● archon-deliver running 37m
Ship the widget pane · continues archon-plan hooks/register.tsx 784 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register, Timer } from 'claude-code'
3
4import type { OutputLine, PaneView, PeerEntry, RowDef, Rows, ServerRun } from '../types'
5import { ADD_USAGE, parseAdd } from './add'
6import type { AddedServer } from './add'
7import { detectRows, LOCKFILES, parseScripts } from './detect'
8import { endWords, errorLines, errorPrompt, findLocalUrl, hhmm, keep, splitPiece, stripAnsi } from './output'
9import { COMPOSE_ID, composeText, otherSessions, peerKey, peerPrefix, REFRESH_MS } from './peers'
10import { checkPort } from './port'
11import type { PortCheck } from './port'
12import { commandText, isQuiet, isUp, newRun, RESTART_CAP, RESTART_WINDOW_MS, rowWords, statusLine } from './servers'
13import { lineCount, OUTPUT_SPEC, OUTPUT_TOOL, outputText, RESTART_SPEC, RESTART_TOOL, RESTART_WAIT_MS } from './tools'
14import type { ToolServer } from './tools'
15import { moveOf, paneGeometry, renderPane, scrollAnchor } from './view'
16import type { PaneActions } from './view'
17
18const PLUGIN = 'dev-server-manager'
19const PANE = 'dev-servers'
20const TITLE = 'Dev servers'
21const COMMAND = 'dev-servers'
22/** Output reaches `$.state` at most about once a second. */
23const FLUSH_MS = 1000
24/** How long a death's toast stays, and Claude's restart's. */
25const DEATH_TOAST_MS = 8000
26const CLAUDE_TOAST_MS = 4000
27/** After its own stop the mod waits this long at most for the listener to let go, polling every POLL_MS. */
28const RELEASE_MS = 3000
29const POLL_MS = 100
30
31const rowsAtom = atom({ plugin: 'dev-server-manager', key: 'rows' } as const, { defs: [], hidden: [] } as Rows)
32const runsAtom = atom({ plugin: 'dev-server-manager', key: 'runs' } as const, {} as Record<string, ServerRun>)
33const outputAtom = atom({ plugin: 'dev-server-manager', key: 'output' } as const, {} as Record<string, OutputLine[]>)
34const viewAtom = atom(
35 { plugin: 'dev-server-manager', key: 'view' } as const,
36 { picked: null, anchor: null, isShowingHidden: false, isFillRefused: false } as PaneView,
37)
38const peersAtom = atom({ plugin: 'dev-server-manager', key: 'peers' } as const, [] as PeerEntry[])
39
40/** A child this module runs: its stream, and whether the mod itself is ending it. */
41type Live = {
42 stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>
43 isStopping: boolean
44}
45
46// What dies with the module on a reload: the children (the engine kills them with it) and the output not yet written.
47const live = new Map<string, Live>()
48let memory: Record<string, OutputLine[]> = {}
49let seq = 0
50let flushedAt = 0
51let isFlushPending = false
52// The servers whose start is under way (the port check), so a second press starts nothing more; a stop calls the start off.
53const opening = new Map<string, { isCancelled: boolean }>()
54// The servers the mod itself stopped, whose port may linger before the next start.
55const stoppedByMod = new Set<string>()
56// Death toasts raised in this tick, shown as one.
57let pendingToasts: string[] = []
58// The status line last shown, so an unchanged one is not set again.
59let shownStatus: string | undefined
60// Claude's restarts waiting for a run's URL or end, by server.
61const waiters = new Map<string, (() => void)[]>()
62// Refreshes this session's store entries and reads the other sessions'; dies with the module.
63let ticker: Timer | undefined
64// The pane's width at its last drawing, which a scroll event does not carry.
65let paneColumns = 60
66// The store entries this module wrote: still its own after /clear gives the session another id.
67const ownEntries = new Set<string>()
68// What a /clear may take from $.state while the servers run on: written back if it does.
69let carried: { rows: Rows; runs: Record<string, ServerRun>; view: PaneView } | undefined
70
71type Config = { isRestartOn: boolean; scripts: string[] }
72
73// The settings as this module's register read them.
74let settings: Config = { isRestartOn: true, scripts: [] }
75
76function parseConfig(options: Record<string, unknown>): Config {
77 return { isRestartOn: options.restart !== false, scripts: parseScripts(options.scripts) }
78}
79
80/** Whether any surface shows the session; asked before each thing the mod starts on its own. */
81async function isShown($: EngineInterface): Promise<boolean> {
82 return (await $.session.surfaces()).length > 0
83}
84
85async function isWindows($: EngineInterface): Promise<boolean> {
86 return (await $.env.get('OS')) === 'Windows_NT'
87}
88
89function join(root: string, path: string): string {
90 return path === '' ? root : `${root.replace(/[\\/]$/, '')}/${path}`
91}
92
93/** Store keys, per project folder. */
94const addedKey = (root: string) => `added:${root}`
95const hiddenKey = (root: string) => `hidden:${root}`
96const portsKey = (root: string) => `ports:${root}`
97
98async function storedAdded($: EngineInterface, root: string): Promise<AddedServer[]> {
99 const value = await $.store.get(addedKey(root)).catch(() => undefined)
100 return Array.isArray(value) ? (value as AddedServer[]) : []
101}
102
103async function storedHidden($: EngineInterface, root: string): Promise<string[]> {
104 const value = await $.store.get(hiddenKey(root)).catch(() => undefined)
105 return Array.isArray(value) ? value.filter((name): name is string => typeof name === 'string') : []
106}
107
108async function storedPorts($: EngineInterface, root: string): Promise<Record<string, number>> {
109 const value = await $.store.get(portsKey(root)).catch(() => undefined)
110 return typeof value === 'object' && value !== null ? (value as Record<string, number>) : {}
111}
112
113/** Reads the project's rows afresh: the root package.json's scripts, the added servers, the hidden rows, the learned ports. */
114async function loadRows($: EngineInterface, config: Config): Promise<Rows> {
115 const root = await $.session.root()
116 const packageJson = await $.fs.read(join(root, 'package.json')).catch(() => undefined)
117 const present: string[] = []
118 for (const [file] of LOCKFILES) {
119 if (await $.fs.exists(join(root, file)).catch(() => false)) present.push(file)
120 }
121 const detected = detectRows(typeof packageJson === 'string' ? packageJson : undefined, present, config.scripts)
122 const ports = await storedPorts($, root)
123 const blocked = detected.conflict.length > 0 ? `lockfiles disagree: ${detected.conflict.join(', ')}` : ''
124 const defs: RowDef[] = detected.rows.map(row => ({ name: row.name, source: 'detected', argv: row.argv, cwd: '', port: ports[row.name] ?? 0, blocked }))
125 for (const added of await storedAdded($, root)) {
126 if (defs.some(def => def.name === added.name)) continue
127 defs.push({ name: added.name, source: 'added', argv: added.argv, cwd: added.cwd ?? '', port: added.port ?? ports[added.name] ?? 0, blocked: '' })
128 }
129 const rows: Rows = { defs, hidden: await storedHidden($, root) }
130 await update($, rowsAtom, () => rows)
131 return rows
132}
133
134/** Adds lines to a server's kept output and writes it to `$.state` at most about once a second. */
135async function addOutput($: EngineInterface, name: string, lines: Omit<OutputLine, 'seq'>[]): Promise<void> {
136 if (lines.length === 0) return
137 memory = { ...memory, [name]: keep(memory[name] ?? [], lines.map(line => ({ ...line, seq: ++seq }))) }
138 if (isFlushPending) return
139 const now = await $.clock.now()
140 const wait = flushedAt + FLUSH_MS - now
141 if (wait <= 0) return flushOutput($)
142 isFlushPending = true
143 $.clock.after(wait, () => {
144 isFlushPending = false
145 void flushOutput($).catch(report($))
146 })
147}
148
149async function flushOutput($: EngineInterface): Promise<void> {
150 await restoreCarried($)
151 flushedAt = await $.clock.now()
152 const snapshot = memory
153 await update($, outputAtom, () => snapshot)
154}
155
156async function setRun($: EngineInterface, name: string, change: (run: ServerRun) => ServerRun): Promise<void> {
157 // The first write after /clear brings back what the old session held, so it is not written over.
158 await restoreCarried($)
159 await update($, runsAtom, runs => ({ ...runs, [name]: change(runs[name] ?? newRun()) }))
160 await showStatus($)
161}
162
163/** The status line from the servers as they stand, in row order; none in a headless session. */
164async function showStatus($: EngineInterface): Promise<void> {
165 if (!(await isShown($))) return
166 const runs = await read($, runsAtom)
167 const { defs } = await read($, rowsAtom)
168 const entries = defs.flatMap(def => {
169 const run = runs[def.name]
170 if (run === undefined || !(isUp(run.status) || run.status === 'crashed')) return []
171 const port = Number(/:(\d+)$/.exec(run.url)?.[1] ?? def.port)
172 return [{ name: def.name, port, isCrashed: run.status === 'crashed' }]
173 })
174 const line = statusLine(entries)
175 if (line === shownStatus) return
176 shownStatus = line
177 $.ui.status(line)
178}
179
180async function defOf($: EngineInterface, name: string): Promise<RowDef | undefined> {
181 return (await read($, rowsAtom)).defs.find(def => def.name === name)
182}
183
184/** Remembers the port a server printed, per project and server, unless one was declared. */
185async function learnPort($: EngineInterface, name: string, port: number): Promise<void> {
186 const def = await defOf($, name)
187 if (def === undefined || def.port > 0) return
188 const root = await $.session.root()
189 await $.store.set(portsKey(root), { ...(await storedPorts($, root)), [name]: port })
190 await update($, rowsAtom, rows => ({ ...rows, defs: rows.defs.map(d => (d.name === name ? { ...d, port } : d)) }))
191}
192
193/** Reads a child's pieces as lines until it ends; the first local URL makes it running. */
194async function follow($: EngineInterface, name: string, entry: Live): Promise<void> {
195 const pending = { stdout: '', stderr: '' }
196 let hasStarted = false
197 let isUrlSeen = false
198 let result: ProcessSpawnResult | undefined
199 try {
200 for (;;) {
201 const step = await entry.stream.next()
202 if (step.done === true) {
203 result = step.value
204 break
205 }
206 hasStarted = true
207 const { stream, text } = step.value
208 const split = splitPiece(pending[stream], text)
209 pending[stream] = split.rest
210 const at = await $.clock.now()
211 const lines = split.lines.map(line => stripAnsi(line))
212 await addOutput($, name, lines.map(line => ({ stream, text: line, at })))
213 if (!isUrlSeen) {
214 const found = lines.map(findLocalUrl).find(url => url !== undefined)
215 if (found !== undefined) {
216 isUrlSeen = true
217 const known = (await defOf($, name))?.port ?? 0
218 const movedFrom = known > 0 && known !== found.port ? known : 0
219 await setRun($, name, run => ({ ...run, status: 'running', url: found.url, movedFrom }))
220 await learnPort($, name, found.port)
221 await recordRunning($, name)
222 wake(name)
223 }
224 }
225 }
226 } catch (error) {
227 // A stream that fails after output is a death like any other, so it goes on to the end below with no exit code.
228 if (!hasStarted && !entry.isStopping) {
229 if (live.get(name) === entry) live.delete(name)
230 wake(name)
231 await forgetRunning($, name)
232 // A command that cannot start rejects the first pull: its message is the row's words.
233 const message = (error instanceof Error ? error.message : String(error)).replace(/^dev-server-manager: \$\.process\.spawn: /, '')
234 await setRun($, name, run => ({ ...run, status: 'stopped', problem: message }))
235 return
236 }
237 }
238 if (live.get(name) === entry) live.delete(name)
239 if (entry.isStopping) return
240 wake(name)
241 await forgetRunning($, name)
242 const at = await $.clock.now()
243 const rest = (['stdout', 'stderr'] as const).filter(stream => pending[stream] !== '')
244 await addOutput($, name, rest.map(stream => ({ stream, text: stripAnsi(pending[stream]), at })))
245 const code = result?.code ?? null
246 const signal = result?.signal ?? null
247 if (code === 0 && signal === null) {
248 await setRun($, name, run => ({ ...run, ...(isQuiet(run, at) ? { crashes: [], restarts: [], isGaveUp: false } : {}), status: 'exited', endedAt: at, lastExit: 0, hasNote: false }))
249 } else await die($, name, code, signal, at)
250}
251
252/** Records a server this session runs in the shared store, for the project's other sessions. */
253async function recordRunning($: EngineInterface, name: string): Promise<void> {
254 const def = await defOf($, name)
255 const run = (await read($, runsAtom))[name]
256 if (def === undefined || run === undefined || !isUp(run.status)) return
257 const port = Number(/:(\d+)$/.exec(run.url)?.[1] ?? def.port)
258 const entry: PeerEntry = { sessionId: await $.session.id(), name, command: commandText(def), port, url: run.url, refreshedAt: await $.clock.now() }
259 const key = peerKey(await $.session.root(), name)
260 ownEntries.add(key)
261 await $.store.set(key, entry)
262}
263
264/** Clears this session's entry for a server that no longer runs. */
265async function forgetRunning($: EngineInterface, name: string): Promise<void> {
266 const key = peerKey(await $.session.root(), name)
267 const entry = (await $.store.get(key).catch(() => undefined)) as PeerEntry | undefined
268 if (ownEntries.delete(key) || entry?.sessionId === (await $.session.id())) await $.store.delete(key)
269}
270
271/** The servers the project's other sessions run, from their fresh store entries. */
272async function readPeers($: EngineInterface): Promise<PeerEntry[]> {
273 const prefix = peerPrefix(await $.session.root())
274 const keys = (await $.store.keys().catch(() => [])).filter(key => key.startsWith(prefix) && !ownEntries.has(key))
275 const values = await Promise.all(keys.map(key => $.store.get(key).catch(() => undefined)))
276 return otherSessions(values, await $.session.id(), await $.clock.now())
277}
278
279/** Every 30 s: this session's entries refreshed, the other sessions' read again (which redraws the pane's times too). */
280async function refresh($: EngineInterface): Promise<void> {
281 for (const name of live.keys()) await recordRunning($, name)
282 const peers = await readPeers($)
283 await update($, peersAtom, () => peers)
284}
285
286function startTicker($: EngineInterface): void {
287 ticker?.cancel()
288 ticker = $.clock.every(REFRESH_MS, () => void refresh($).catch(report($)))
289}
290
291/** The system prompt's section on what runs, here and in the project's other sessions; none while nothing runs. */
292async function composeSection($: EngineInterface): Promise<string | undefined> {
293 const { defs } = await read($, rowsAtom)
294 const runs = await read($, runsAtom)
295 const own = defs.flatMap(def => {
296 const run = runs[def.name]
297 return run !== undefined && isUp(run.status) ? [{ name: def.name, command: commandText(def), url: run.url, state: run.status }] : []
298 })
299 const others = (await readPeers($))
300 .filter(peer => !own.some(server => server.name === peer.name))
301 .map(peer => ({ name: peer.name, command: peer.command, url: peer.url, state: 'running in another session' }))
302 return composeText([...own, ...others])
303}
304
305/**
306 * After /clear the module and its servers run on under a new session. Should
307 * the engine have emptied the mod's `$.state` with the old session, the rows,
308 * runs, output and pane place it held are written back.
309 */
310async function restoreCarried($: EngineInterface): Promise<void> {
311 const held = carried
312 if (held === undefined) return
313 carried = undefined
314 await update($, rowsAtom, rows => (rows.defs.length === 0 ? held.rows : rows))
315 await update($, runsAtom, runs => ({ ...held.runs, ...runs }))
316 await update($, viewAtom, view => (view.picked === null && view.anchor === null ? held.view : view))
317 await flushOutput($)
318}
319
320/** Marks the lines a death picked as its error in the kept output. */
321function markError(name: string, count: number): void {
322 const list = memory[name] ?? []
323 let left = count
324 const marked = [...list]
325 for (let at = marked.length - 1; at >= 0 && left > 0; at -= 1) {
326 const line = marked[at]!
327 if (line.stream === 'divider') break
328 if (line.stream === 'note') continue
329 marked[at] = { ...line, isError: true }
330 left -= 1
331 }
332 memory = { ...memory, [name]: marked }
333}
334
335/**
336 * A death: a non-zero exit, or a signal the mod did not send. It toasts, and
337 * restarts while the setting is on and the cap of 3 in 2 minutes allows; in a
338 * headless session it does neither, and the row stays crashed.
339 */
340async function die($: EngineInterface, name: string, code: number | null, signal: string | null, at: number): Promise<void> {
341 const def = await defOf($, name)
342 const lines = errorLines(memory[name] ?? [])
343 markError(name, lines.length)
344 await flushOutput($)
345 const death = { at, code, signal, command: def === undefined ? name : commandText(def), lines }
346 const isShowing = await isShown($)
347 let restartNumber = 0
348 await setRun($, name, run => {
349 // After 10 quiet minutes the earlier deaths are behind it, and this one counts from 1.
350 const earlier = isQuiet(run, at) ? { crashes: [], restarts: [] } : run
351 const restarts = earlier.restarts.filter(time => at - time < RESTART_WINDOW_MS)
352 const canRestart = isShowing && settings.isRestartOn && restarts.length < RESTART_CAP
353 restartNumber = canRestart ? restarts.length + 1 : 0
354 return {
355 ...run,
356 status: 'crashed',
357 endedAt: at,
358 lastExit: code,
359 death,
360 crashes: [...earlier.crashes, at],
361 restarts,
362 isGaveUp: isShowing && settings.isRestartOn && !canRestart,
363 hasNote: false,
364 }
365 })
366 if (!isShowing) return
367 const ended = endWords(death)
368 // The restart is charged and told only once it has spawned: a port taken, say, leaves the row as that and the cap whole.
369 const isRestarted = restartNumber > 0 && (await start($, name, { divider: `crashed (${ended}) · restarted` }))
370 if (isRestarted) await setRun($, name, run => ({ ...run, restarts: [...run.restarts, at], hasNote: true }))
371 toastDeath($, isRestarted ? `✗ ${name} crashed (${ended}), restarting (${restartNumber}/${RESTART_CAP})` : `✗ ${name} crashed (${ended})`)
372}
373
374/** Raises a death's toast; several deaths in one tick are one toast. */
375function toastDeath($: EngineInterface, text: string): void {
376 pendingToasts.push(text)
377 if (pendingToasts.length > 1) return
378 $.clock.after(0, () => {
379 const texts = pendingToasts
380 pendingToasts = []
381 $.ui.toast(texts.join(' · '), { timeoutMs: DEATH_TOAST_MS })
382 })
383}
384
385/**
386 * The port check before a start. After the mod's own stop the listener lingers
387 * up to most of a second, so it polls until the port is free, about 3 s at most,
388 * and only then names a holder that is still there.
389 */
390async function portCheck($: EngineInterface, name: string, port: number): Promise<PortCheck> {
391 const run = (argv: readonly string[]) => $.process.run(argv)
392 const windows = await isWindows($)
393 if (stoppedByMod.delete(name)) {
394 const deadline = (await $.clock.now()) + RELEASE_MS
395 for (;;) {
396 const polled = await checkPort(run, windows, port, false)
397 if (polled.status !== 'taken') return polled
398 if ((await $.clock.now()) + POLL_MS > deadline) break
399 await $.clock.sleep(POLL_MS)
400 }
401 }
402 return checkPort(run, windows, port)
403}
404
405/** How a start came about: the divider's words, and whether the person (or Claude) handled the server by hand. */
406type StartReason = { divider: string; isByHand?: boolean; isAfterReload?: boolean }
407
408/** Starts a server: the port check first, then the child, with no shell. One start at a time per server. True when the child spawned. */
409async function start($: EngineInterface, name: string, reason: StartReason): Promise<boolean> {
410 if (live.has(name) || opening.has(name)) return false
411 const pending = { isCancelled: false }
412 opening.set(name, pending)
413 try {
414 return await open($, name, reason, pending)
415 } finally {
416 if (opening.get(name) === pending) opening.delete(name)
417 }
418}
419
420async function open($: EngineInterface, name: string, reason: StartReason, pending: { isCancelled: boolean }): Promise<boolean> {
421 if (!(await isShown($))) return false
422 const def = await defOf($, name)
423 if (def === undefined || def.blocked !== '') return false
424 let problem = ''
425 if (def.port > 0) {
426 const check = await portCheck($, name, def.port)
427 if (pending.isCancelled) return false
428 if (check.status === 'taken') {
429 await setRun($, name, run => ({ ...run, status: 'port taken', holder: check.words, problem: '' }))
430 return false
431 }
432 if (check.status === 'unchecked') problem = 'port not checked'
433 }
434 const root = await $.session.root()
435 const now = await $.clock.now()
436 if (pending.isCancelled) return false
437 await addOutput($, name, [{ stream: 'divider', text: `── ${reason.divider} ${hhmm(now)} ──`, at: now }])
438 const handled = reason.isByHand === true ? { crashes: [], restarts: [], isGaveUp: false, hasNote: false } : {}
439 await setRun($, name, run => ({
440 ...run,
441 ...handled,
442 status: 'starting',
443 url: '',
444 startedAt: now,
445 endedAt: 0,
446 movedFrom: 0,
447 isAfterReload: reason.isAfterReload === true,
448 problem,
449 holder: '',
450 }))
451 if (pending.isCancelled) return false
452 const entry: Live = {
453 stream: $.process.spawn({ argv: def.argv, cwd: join(root, def.cwd), env: { PYTHONUNBUFFERED: '1' } }),
454 isStopping: false,
455 }
456 live.set(name, entry)
457 void follow($, name, entry).catch(report($))
458 await recordRunning($, name)
459 await offerTools($, name)
460 return true
461}
462
463/**
464 * Registers the two tools at every bring-up: the first time a server runs in
465 * the session, and again after a reload, a name registered again being
466 * replaced. Before the session binds the call rejects: a dim line in the output says so.
467 */
468async function offerTools($: EngineInterface, name: string): Promise<void> {
469 try {
470 await $.tool.register(OUTPUT_SPEC)
471 await $.tool.register(RESTART_SPEC)
472 } catch (error) {
473 const at = await $.clock.now()
474 const message = error instanceof Error ? error.message : String(error)
475 await addOutput($, name, [{ stream: 'note', text: `could not offer Claude the dev-servers tools: ${message}`, at }])
476 }
477}
478
479/** Resolves when `name`'s current run prints its URL or ends. */
480function untilUp(name: string): Promise<void> {
481 return new Promise(resolve => waiters.set(name, [...(waiters.get(name) ?? []), resolve]))
482}
483
484function wake(name: string): void {
485 const waiting = waiters.get(name) ?? []
486 waiters.delete(name)
487 for (const resolve of waiting) resolve()
488}
489
490/**
491 * Stops a server the mod runs: closing its stream kills the whole tree, and
492 * reads no exit code. A start still under way is called off, and nothing spawns.
493 */
494async function stop($: EngineInterface, name: string): Promise<void> {
495 const pending = opening.get(name)
496 if (pending !== undefined) {
497 pending.isCancelled = true
498 opening.delete(name)
499 }
500 const entry = live.get(name)
501 if (entry === undefined && pending === undefined) return
502 if (entry !== undefined) {
503 entry.isStopping = true
504 live.delete(name)
505 stoppedByMod.add(name)
506 await entry.stream.return(undefined as never).catch(() => undefined)
507 await forgetRunning($, name)
508 }
509 const now = await $.clock.now()
510 await setRun($, name, run => ({ ...run, status: 'stopped', endedAt: now, hasNote: false, crashes: [], restarts: [], isGaveUp: false }))
511}
512
513/** Logs a failure of work the mod started on its own. */
514function report($: EngineInterface): (error: unknown) => void {
515 return error => $.ui.log(`${PLUGIN}: ${error instanceof Error ? error.message : String(error)}`)
516}
517
518/** What the pane's buttons do. */
519function paneActions($: EngineInterface, config: Config): PaneActions {
520 return {
521 pick: name => void update($, viewAtom, view => ({ ...view, picked: view.picked === name ? null : name, anchor: null, isFillRefused: false })),
522 start: name => void start($, name, { divider: 'started', isByHand: true }).catch(report($)),
523 stop: name => void stop($, name).catch(report($)),
524 restart: name => void restartByHand($, name).catch(report($)),
525 fillError: name => void fillError($, name).catch(report($)),
526 hide: name =>
527 void (async () => {
528 await setHidden($, config, name, true)
529 await update($, viewAtom, view => (view.picked === name ? { ...view, picked: null } : view))
530 })().catch(report($)),
531 unhide: name => void setHidden($, config, name, false).catch(report($)),
532 toggleHidden: () => void update($, viewAtom, view => ({ ...view, isShowingHidden: !view.isShowingHidden })),
533 latest: () => void update($, viewAtom, view => ({ ...view, anchor: null })),
534 settings: () => void $.command.run({ command: 'mod-settings' }).catch(report($)),
535 }
536}
537
538/**
539 * A fresh module after a reload that changed it: the old module's servers died
540 * with it, so each one `$.state` still lists as up starts again. In a headless
541 * session nothing new starts, and they read stopped.
542 */
543async function bringBack($: EngineInterface): Promise<void> {
544 if (Object.keys(memory).length === 0) {
545 memory = await read($, outputAtom)
546 seq = Math.max(seq, ...Object.values(memory).flat().map(line => line.seq))
547 }
548 const runs = await read($, runsAtom)
549 const isShowing = await isShown($)
550 for (const [name, run] of Object.entries(runs)) {
551 if (!isUp(run.status) || live.has(name)) continue
552 if (isShowing) await start($, name, { divider: 'restarted after reload', isAfterReload: true })
553 else await setRun($, name, current => ({ ...current, status: 'stopped' }))
554 }
555}
556
557/**
558 * error → prompt: appends the latest death's error to whatever is typed, after
559 * a blank line, and sends nothing. The prompt box needs the keyboard, and
560 * closing the pane is the one way a mod hands it back, so the pane is closed
561 * and opened again without the keys. On a running server the note and button go.
562 */
563async function fillError($: EngineInterface, name: string): Promise<void> {
564 const death = (await read($, runsAtom))[name]?.death
565 if (death === null || death === undefined) return
566 await $.ui.close({ id: PANE })
567 let isFilled = false
568 try {
569 isFilled = (await $.prompt.fill({ text: `\n\n${errorPrompt(name, death)}`, mode: 'append' })).isFilled
570 } finally {
571 await $.ui.open({ id: PANE, title: TITLE })
572 }
573 // Only the engine's own refusal names its cause (no_composer, dialog), so any refusal says the prompt is out of reach.
574 await update($, viewAtom, view => ({ ...view, isFillRefused: !isFilled }))
575 if (isFilled) await setRun($, name, run => (run.status === 'running' ? { ...run, hasNote: false } : run))
576}
577
578/** Hides a detected row, or shows it again; kept per project. */
579async function setHidden($: EngineInterface, config: Config, name: string, isHidden: boolean): Promise<void> {
580 const root = await $.session.root()
581 const hidden = (await storedHidden($, root)).filter(other => other !== name)
582 await $.store.set(hiddenKey(root), isHidden ? [...hidden, name] : hidden)
583 await loadRows($, config)
584}
585
586/** `/dev-servers add|remove|unhide`: the reply the transcript shows. */
587async function runSubcommand($: EngineInterface, config: Config, args: string): Promise<string> {
588 const [verb = '', ...words] = args.trim().split(/\s+/)
589 const rest = args.trim().slice(verb.length).trim()
590 const root = await $.session.root()
591 const rows = await loadRows($, config)
592 if (verb === 'add') {
593 const added = await storedAdded($, root)
594 const parsed = parseAdd(rest, { detected: rows.defs.filter(def => def.source === 'detected').map(def => def.name), added: added.map(server => server.name) })
595 if ('error' in parsed) return parsed.error
596 await $.store.set(addedKey(root), [...added, parsed.server])
597 await loadRows($, config)
598 return `Added ${parsed.server.name}: ${commandText({ ...parsed.server, source: 'added', cwd: '', port: 0, blocked: '' })}`
599 }
600 if (verb === 'remove') {
601 const added = await storedAdded($, root)
602 const name = words[0] ?? ''
603 if (!added.some(server => server.name === name)) {
604 return `No added server is named ${name}. Added servers: ${added.length === 0 ? 'none' : added.map(server => server.name).join(', ')}.`
605 }
606 await stop($, name)
607 await $.store.set(addedKey(root), added.filter(server => server.name !== name))
608 await update($, viewAtom, view => (view.picked === name ? { ...view, picked: null } : view))
609 await loadRows($, config)
610 return `Removed ${name}.`
611 }
612 if (verb === 'unhide') {
613 const name = words[0] ?? ''
614 if (!rows.hidden.includes(name)) return `${name} is not hidden.`
615 await setHidden($, config, name, false)
616 return `${name} is shown again.`
617 }
618 return `Use /dev-servers to open the pane, ${ADD_USAGE}, /dev-servers remove <name> or /dev-servers unhide <name>.`
619}
620
621/** A server as the tools describe it, its output the freshest the module holds. */
622async function toolServer($: EngineInterface, def: RowDef): Promise<ToolServer> {
623 const run = (await read($, runsAtom))[def.name] ?? newRun()
624 return { name: def.name, command: commandText(def), status: run.status, url: run.url, lastExit: run.lastExit, lines: memory[def.name] ?? [] }
625}
626
627/** The tool's answer, or its refusal as the error the model reads. */
628type ToolAnswer = { result: string } | { deny: string }
629
630function unknownServer(name: string, defs: readonly RowDef[]): ToolAnswer {
631 return { deny: `No dev server is named ${name}. The servers are: ${defs.length === 0 ? 'none' : defs.map(def => def.name).join(', ')}.` }
632}
633
634/**
635 * `output`: the named server's header and current run, or with none named the
636 * one running server, or the running names when several run. Works headless.
637 */
638async function answerOutput($: EngineInterface, server: unknown, lines: unknown): Promise<ToolAnswer> {
639 const { defs } = await read($, rowsAtom)
640 const runs = await read($, runsAtom)
641 let def: RowDef | undefined
642 if (typeof server === 'string' && server !== '') {
643 def = defs.find(candidate => candidate.name === server)
644 if (def === undefined) return unknownServer(server, defs)
645 } else {
646 const up = defs.filter(candidate => isUp(runs[candidate.name]?.status ?? 'stopped'))
647 if (up.length > 1) return { result: `Several dev servers run: ${up.map(candidate => candidate.name).join(', ')}. Call again with server set to one of them.` }
648 def = up[0] ?? (Object.keys(runs).length === 1 ? defs.find(candidate => candidate.name in runs) : undefined)
649 if (def === undefined) return { result: `No dev server runs. The servers are: ${defs.map(candidate => candidate.name).join(', ') || 'none'}.` }
650 }
651 return { result: outputText(await toolServer($, def), lineCount(lines)) }
652}
653
654/**
655 * `restart` from Claude: only a running or crashed server of this session, never
656 * headless. It counts as handling the server by hand, toasts, and waits for the
657 * new run's URL or end, 30 s at most, then answers what `output` would.
658 */
659async function restartByClaude($: EngineInterface, server: unknown): Promise<ToolAnswer> {
660 if (!(await isShown($))) return { deny: 'Restart is not available in a headless session.' }
661 const { defs } = await read($, rowsAtom)
662 const name = typeof server === 'string' ? server : ''
663 const def = defs.find(candidate => candidate.name === name)
664 if (def === undefined) return unknownServer(name, defs)
665 if ((await read($, peersAtom)).some(peer => peer.name === name) && !live.has(name)) {
666 return { deny: `${name} runs in another session; restart it from that session's dev-servers pane.` }
667 }
668 const status = (await read($, runsAtom))[name]?.status ?? 'stopped'
669 if (!isUp(status) && status !== 'crashed') {
670 return { deny: `${name} ${status === 'exited' ? 'exited' : `is ${status}`}: start it from the dev-servers pane.` }
671 }
672 await stop($, name)
673 if (!(await start($, name, { divider: 'restarted by Claude', isByHand: true }))) {
674 // It did not spawn (the port is taken, say): the row's state and words say why.
675 const run = (await read($, runsAtom))[name]
676 const words = rowWords({ def, run, peer: undefined }, await $.clock.now()).text
677 return { deny: `${name} did not restart (${run?.status ?? 'stopped'}): ${words}` }
678 }
679 $.ui.toast(`${name} restarted by Claude`, { timeoutMs: CLAUDE_TOAST_MS })
680 if (live.has(name)) await Promise.race([untilUp(name), $.clock.sleep(RESTART_WAIT_MS)])
681 return { result: outputText(await toolServer($, def), lineCount(undefined)) }
682}
683
684/** The person's restart: stop, then start again. */
685async function restartByHand($: EngineInterface, name: string): Promise<void> {
686 await stop($, name)
687 await start($, name, { divider: 'restarted', isByHand: true })
688}
689
690export const register: Register = (on, options) => {
691 const config = parseConfig(options)
692 settings = config
693
694 on('session.start', async ($, e, next) => {
695 const started = await next(e)
696 await $.command.register({ name: COMMAND, description: 'Open the dev servers pane: start, stop and watch the project dev servers' })
697 await loadRows($, config)
698 await update($, peersAtom, () => [])
699 await bringBack($)
700 const peers = await readPeers($)
701 await update($, peersAtom, () => peers)
702 if (await isShown($)) startTicker($)
703 return started
704 })
705
706 on('session.attach', async ($, e, next) => {
707 const attached = await next(e)
708 await restoreCarried($)
709 if (await isShown($)) startTicker($)
710 return attached
711 })
712
713 on('prompt.compose', async ($, e, next) => {
714 await restoreCarried($)
715 const composed = await next(e)
716 // A headless session hears nothing of the servers.
717 if (e.surfaces.length === 0) return composed
718 const text = await composeSection($)
719 return text === undefined ? composed : { ...composed, sections: [...composed.sections, { id: COMPOSE_ID, text, scope: 'session' as const }] }
720 })
721
722 on('session.end', async ($, e, next) => {
723 if (e.reason === 'clear' && live.size > 0) {
724 carried = { rows: await read($, rowsAtom), runs: await read($, runsAtom), view: await read($, viewAtom) }
725 }
726 const ended = await next(e)
727 // After /clear the process goes on under a new session, and so do its servers.
728 if (e.reason !== 'clear') {
729 ticker?.cancel()
730 ticker = undefined
731 for (const name of new Set([...live.keys(), ...opening.keys()])) await stop($, name).catch(report($))
732 }
733 return ended
734 })
735
736 on('tool.call', { tool: OUTPUT_TOOL }, async ($, e) => answerOutput($, e.server, e.lines))
737 on('tool.call', { tool: RESTART_TOOL }, async ($, e) => restartByClaude($, e.server))
738 // Reading stored lines and a process's state asks no one; restart goes through the normal check.
739 on('tool.check', { tool: OUTPUT_TOOL }, () => ({ decision: 'allow' }))
740
741 on('command.run', { command: COMMAND }, async ($, e) => {
742 await restoreCarried($)
743 if (e.args.trim() !== '') return { text: await runSubcommand($, config, e.args) }
744 await loadRows($, config)
745 await refresh($)
746 await $.ui.open({ id: PANE, title: TITLE, focus: true })
747 return { text: 'Dev servers pane opened.' }
748 })
749
750 // The output pages under a title and table that never move: the engine's window stays put
751 // (no next) while the mod moves its own slice; below the floor the engine scrolls the body.
752 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
753 const data = {
754 rows: await read($, rowsAtom),
755 runs: await read($, runsAtom),
756 output: await read($, outputAtom),
757 view: await read($, viewAtom),
758 peers: await read($, peersAtom),
759 now: await $.clock.now(),
760 }
761 const geometry = paneGeometry(data, paneColumns, e.bodyRows)
762 if (!geometry.isBounded) return next(e)
763 if (e.pointer !== undefined && e.pointer.row < geometry.used) return {}
764 const anchor = scrollAnchor(geometry.lines, geometry.outputRows, data.view.anchor, moveOf(e.by, e.bodyRows, e.contentRows))
765 await update($, viewAtom, view => ({ ...view, anchor }))
766 return {}
767 })
768
769 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
770 paneColumns = e.props.bodyColumns
771 const hasSettings = (await $.command.list().catch(() => [])).some(command => command.name === 'mod-settings')
772 return renderPane($.ui.resolve(e), e.props.bodyColumns, e.props.scroll.bodyRows, {
773 rows: await read($, rowsAtom),
774 runs: await read($, runsAtom),
775 output: await read($, outputAtom),
776 view: await read($, viewAtom),
777 peers: await read($, peersAtom),
778 now: await $.clock.now(),
779 hasSettings,
780 actions: paneActions($, config),
781 })
782 })
783}
784hooks/add.ts 114 lines1// `/dev-servers add`: splits the person's line into a name, the mod's options and an argv.
2
3/** How the add command is written, for the replies that refuse one. */
4export const ADD_USAGE = '/dev-servers add <name> [--port N] [--cwd dir] <command…>'
5
6/** A server the person added: kept per project in the store. */
7export type AddedServer = {
8 name: string
9 argv: string[]
10 /** The port it serves on, when the person declared one. */
11 port?: number
12 /** Where it runs, relative to the project's root; the root when absent. */
13 cwd?: string
14}
15
16/** The names already rows, so an add cannot take one. */
17export type TakenNames = {
18 detected: readonly string[]
19 added: readonly string[]
20}
21
22export type AddResult = { server: AddedServer } | { error: string }
23
24/** Shell syntax, outside quotes, that only a shell would act on. */
25const SHELL_OPERATORS = ['&&', '||', '|', ';', '>', '<', '`', '$(']
26const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
27const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
28
29type Word = { text: string; isQuoted: boolean }
30
31/** Splits on spaces with "…" and '…' quoting; undefined for an unclosed quote. */
32function splitWords(line: string): Word[] | undefined {
33 const words: Word[] = []
34 let current: Word | undefined
35 let quote = ''
36 for (const char of line) {
37 if (quote !== '') {
38 if (char === quote) quote = ''
39 else current!.text += char
40 } else if (char === '"' || char === "'") {
41 quote = char
42 current ??= { text: '', isQuoted: true }
43 current.isQuoted = true
44 } else if (char === ' ' || char === '\t') {
45 if (current !== undefined) words.push(current)
46 current = undefined
47 } else {
48 current ??= { text: '', isQuoted: false }
49 current.text += char
50 }
51 }
52 if (quote !== '') return undefined
53 if (current !== undefined) words.push(current)
54 return words
55}
56
57/** The shell syntax a line holds outside its quotes, or undefined. */
58function shellSyntax(line: string): string | undefined {
59 let bare = ''
60 let quote = ''
61 for (const char of line) {
62 if (quote !== '') {
63 if (char === quote) quote = ''
64 bare += ' '
65 } else if (char === '"' || char === "'") {
66 quote = char
67 bare += ' '
68 } else bare += char
69 }
70 return SHELL_OPERATORS.find(operator => bare.includes(operator))
71}
72
73function refuseShell(what: string): AddResult {
74 return {
75 error:
76 `${what} needs a shell, and dev-servers runs commands without a shell. ` +
77 'Put the command in a package.json script and add the row for that script, or run the script itself.',
78 }
79}
80
81/** Parses the arguments of `/dev-servers add`; refuses shell syntax, a taken name, and a malformed line. */
82export function parseAdd(args: string, taken: TakenNames): AddResult {
83 const operator = shellSyntax(args)
84 if (operator !== undefined) return refuseShell(`\`${operator}\``)
85 const words = splitWords(args.trim())
86 if (words === undefined) return { error: `The command has an unclosed quote. Use: ${ADD_USAGE}` }
87 const [first, ...rest] = words
88 if (first === undefined || !NAME.test(first.text)) return { error: `Name the server first. Use: ${ADD_USAGE}` }
89 const name = first.text
90 if (taken.detected.includes(name)) return { error: `${name} is already a server here (a package.json script). Pick another name.` }
91 if (taken.added.includes(name)) return { error: `${name} is already a server here (added with /dev-servers add). Pick another name.` }
92
93 const server: AddedServer = { name, argv: [] }
94 let at = 0
95 for (;;) {
96 const option = rest[at]
97 if (option?.isQuoted !== false || (option.text !== '--port' && option.text !== '--cwd')) break
98 const value = rest[at + 1]
99 if (value === undefined) return { error: `${option.text} needs a value. Use: ${ADD_USAGE}` }
100 if (option.text === '--port') {
101 const port = Number(value.text)
102 if (!Number.isInteger(port) || port < 1 || port > 65535) return { error: `--port takes a port number (1-65535), not ${value.text}.` }
103 server.port = port
104 } else server.cwd = value.text
105 at += 2
106 }
107 const command = rest.slice(at)
108 if (command.length === 0) return { error: `Give the command to run after the name. Use: ${ADD_USAGE}` }
109 const lead = command[0]!
110 if (!lead.isQuoted && ASSIGNMENT.test(lead.text)) return refuseShell(`Setting a variable (\`${lead.text}\`)`)
111 server.argv = command.map(word => word.text)
112 return { server }
113}
114hooks/detect.ts 72 lines1// Which package.json scripts become rows, and which package manager runs them.
2
3/** The scripts setting's default: the names a dev server's script usually has. */
4export const DEFAULT_SCRIPTS: readonly string[] = ['dev', 'start', 'serve', 'preview']
5
6/** Each lockfile and the package manager that writes it, in the order a conflict names them. */
7export const LOCKFILES: readonly (readonly [file: string, manager: string])[] = [
8 ['package-lock.json', 'npm'],
9 ['pnpm-lock.yaml', 'pnpm'],
10 ['yarn.lock', 'yarn'],
11 ['bun.lock', 'bun'],
12 ['bun.lockb', 'bun'],
13]
14
15/** A row detected from the root package.json: the script's name and the argv that runs it. */
16export type DetectedRow = {
17 name: string
18 argv: string[]
19}
20
21export type Detected = {
22 rows: DetectedRow[]
23 /** The lockfiles that disagree when package.json names no packageManager; empty when the manager is known. */
24 conflict: string[]
25}
26
27/** The scripts setting: split on commas, trimmed, empties dropped; nothing left reads the default. */
28export function parseScripts(value: unknown): string[] {
29 const names = typeof value === 'string' ? value.split(',').map(name => name.trim()).filter(name => name !== '') : []
30 return names.length === 0 ? [...DEFAULT_SCRIPTS] : names
31}
32
33function parsePackage(text: string | undefined): Record<string, unknown> | undefined {
34 if (text === undefined) return undefined
35 try {
36 const parsed: unknown = JSON.parse(text)
37 return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined
38 } catch {
39 return undefined
40 }
41}
42
43/**
44 * The package manager: the packageManager field (`pnpm@9.1.0` is pnpm), else
45 * the one lockfile present, else npm. More than one lockfile with no field is
46 * a conflict the mod does not guess through, since the wrong manager can stall.
47 */
48function pickManager(field: unknown, lockfiles: readonly string[]): { manager: string; conflict: string[] } {
49 if (typeof field === 'string' && field.trim() !== '') return { manager: field.trim().split('@')[0] ?? 'npm', conflict: [] }
50 const present = LOCKFILES.filter(([file]) => lockfiles.includes(file))
51 // bun.lock and bun.lockb are one manager's two lockfiles: only different managers conflict.
52 if (new Set(present.map(([, manager]) => manager)).size > 1) return { manager: '', conflict: present.map(([file]) => file) }
53 return { manager: present[0]?.[1] ?? 'npm', conflict: [] }
54}
55
56/**
57 * The rows the root package.json gives: its scripts the setting names, in the
58 * setting's order, each run as `<manager> run <script>`. `packageJson` is the
59 * file's text, undefined when there is none; `lockfiles` the lockfile names
60 * present beside it.
61 */
62export function detectRows(packageJson: string | undefined, lockfiles: readonly string[], scripts: readonly string[]): Detected {
63 const parsed = parsePackage(packageJson)
64 const defined = parsed?.scripts
65 if (parsed === undefined || typeof defined !== 'object' || defined === null) return { rows: [], conflict: [] }
66 const { manager, conflict } = pickManager(parsed.packageManager, lockfiles)
67 const rows = scripts
68 .filter(name => typeof (defined as Record<string, unknown>)[name] === 'string')
69 .map(name => ({ name, argv: [manager, 'run', name] }))
70 return { rows, conflict }
71}
72hooks/output.ts 92 lines1// A server's output: whole lines from the pieces, ANSI stripped, the local URL,
2// the kept list, and the lines a death picks as its error.
3
4import type { Death, OutputLine } from '../types'
5
6/** The most lines kept per server, dividers included. */
7export const KEPT_LINES = 500
8/** The longest line kept; longer ones are cut with `…`. */
9export const LINE_CHARS = 2000
10/** The most lines a death's error takes, and the most characters, cut from the top. */
11export const ERROR_LINES = 40
12export const ERROR_CHARS = 4000
13
14// eslint-disable-next-line no-control-regex
15const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]/g
16const LOCAL_URL = /(https?):\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d{1,5})/
17
18/** Text without colour and cursor codes. */
19export function stripAnsi(text: string): string {
20 return text.replace(ANSI, '')
21}
22
23/**
24 * Adds a piece to what was pending and splits off the whole lines: a line may
25 * span two pieces and one piece may hold several. `rest` waits for its end.
26 */
27export function splitPiece(pending: string, piece: string): { lines: string[]; rest: string } {
28 const parts = (pending + piece).split('\n')
29 const rest = parts.pop() ?? ''
30 return { lines: parts.map(part => (part.endsWith('\r') ? part.slice(0, -1) : part)), rest }
31}
32
33/** The first local URL a stripped line prints, scheme, host and port only; `0.0.0.0` reads as localhost. */
34export function findLocalUrl(line: string): { url: string; port: number } | undefined {
35 const match = LOCAL_URL.exec(line)
36 if (match === null) return undefined
37 const [, scheme, host, digits] = match
38 const port = Number(digits)
39 if (port < 1 || port > 65535) return undefined
40 return { url: `${scheme}://${host === '0.0.0.0' ? 'localhost' : host}:${port}`, port }
41}
42
43/** A line cut to the kept length. */
44export function cutLine(text: string): string {
45 return text.length > LINE_CHARS ? `${text.slice(0, LINE_CHARS)}…` : text
46}
47
48/** The kept list with `added` after it: each line cut, the oldest dropped past the cap. */
49export function keep(list: readonly OutputLine[], added: readonly OutputLine[]): OutputLine[] {
50 const next = [...list, ...added.map(line => (line.text.length > LINE_CHARS ? { ...line, text: cutLine(line.text) } : line))]
51 return next.length > KEPT_LINES ? next.slice(next.length - KEPT_LINES) : next
52}
53
54/** The lines of the current run: everything after the last divider. */
55export function currentRun(list: readonly OutputLine[]): OutputLine[] {
56 let start = 0
57 list.forEach((line, at) => {
58 if (line.stream === 'divider') start = at + 1
59 })
60 return list.slice(start).filter(line => line.stream === 'stdout' || line.stream === 'stderr')
61}
62
63/** A death's error: the last 40 lines of the run that died, both streams in order, at most 4,000 characters cut from the top. */
64export function errorLines(list: readonly OutputLine[]): string[] {
65 const lines = currentRun(list).slice(-ERROR_LINES).map(line => line.text)
66 let size = lines.join('\n').length
67 while (size > ERROR_CHARS && lines.length > 0) {
68 size -= (lines.shift()?.length ?? 0) + 1
69 }
70 return lines
71}
72
73/** `14:02`, in the machine's own time. */
74export function hhmm(at: number): string {
75 const date = new Date(at)
76 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
77}
78
79/** How a death ended, in words: `exit 3` or `signal SIGTERM`. */
80export function endWords(death: Pick<Death, 'code' | 'signal'>): string {
81 return death.signal !== null ? `signal ${death.signal}` : `exit ${death.code ?? '?'}`
82}
83
84/** What error → prompt fills: a header naming the server and its death, then its last lines in a fenced block. */
85export function errorPrompt(name: string, death: Death): string {
86 const header = `The dev server ${name} (${death.command}) crashed with ${endWords(death)} at ${hhmm(death.at)}.`
87 if (death.lines.length === 0) return `${header} It printed nothing before it exited.`
88 const longest = Math.max(2, ...death.lines.map(line => Math.max(0, ...(line.match(/`+/g) ?? []).map(run => run.length))))
89 const fence = '`'.repeat(longest + 1)
90 return `${header} Its last output:\n\n${fence}\n${death.lines.join('\n')}\n${fence}`
91}
92hooks/peers.ts 46 lines1// Servers other sessions of the project run, through their entries in the shared store,
2// and the system prompt's section on what runs.
3
4import type { PeerEntry } from '../types'
5import { OUTPUT_TOOL, RESTART_TOOL } from './tools'
6
7/** How often a session refreshes its own entries, and how old another's may be before it is ignored. */
8export const REFRESH_MS = 30_000
9export const STALE_MS = 90_000
10
11export const COMPOSE_ID = 'dev-server-manager:servers'
12
13/** The store key of a running server's entry: per project and name. */
14export function peerKey(root: string, name: string): string {
15 return `${peerPrefix(root)}${name}`
16}
17
18export function peerPrefix(root: string): string {
19 return `running:${root}:`
20}
21
22function isEntry(value: unknown): value is PeerEntry {
23 if (typeof value !== 'object' || value === null) return false
24 const entry = value as Record<string, unknown>
25 return typeof entry.sessionId === 'string' && typeof entry.name === 'string' && typeof entry.command === 'string' &&
26 typeof entry.port === 'number' && typeof entry.url === 'string' && typeof entry.refreshedAt === 'number'
27}
28
29/** Other sessions' entries, malformed and stale ones (not refreshed for 90 s) left out. */
30export function otherSessions(values: readonly unknown[], sessionId: string, now: number): PeerEntry[] {
31 return values.filter(isEntry).filter(entry => entry.sessionId !== sessionId && now - entry.refreshedAt < STALE_MS)
32}
33
34/** A server the section names. */
35export type Running = { name: string; command: string; url: string; state: string }
36
37/** The `prompt.compose` section while anything runs: each server's command, URL and state, and the two tools. */
38export function composeText(running: readonly Running[]): string | undefined {
39 if (running.length === 0) return undefined
40 return [
41 'Dev servers: the dev-servers pane started these, so do not start another copy of one.',
42 ...running.map(server => `- ${server.name}: ${server.command}, ${server.url === '' ? 'no URL yet' : server.url}, ${server.state}`),
43 `Read a server output with ${OUTPUT_TOOL}; restart one with ${RESTART_TOOL}.`,
44 ].join('\n')
45}
46hooks/port.ts 100 lines1// The port check before a start: is anything listening on the port, and who.
2
3/** A listener on the port: its process name ('' when unknown) and PID (0 when unknown). */
4export type Holder = { name: string; pid: number }
5
6/** What the check found: free, taken (with the row's words), or not checked because the tool is missing. */
7export type PortCheck = { status: 'free' } | { status: 'taken'; words: string } | { status: 'unchecked' }
8
9/** `$.process.run` as the check uses it; rejects when the command cannot start. */
10export type Runner = (argv: readonly string[]) => Promise<{ exitCode: number; stdout: string }>
11
12/** Where the port ends a `host:port` address, the port split at the last colon so `[::1]:5173` reads. */
13function portOf(address: string): number {
14 return Number(address.slice(address.lastIndexOf(':') + 1))
15}
16
17/**
18 * The PIDs listening on `port` in `netstat -ano`: TCP rows whose local address
19 * ends in the port and whose foreign address is `0.0.0.0:0` or `[::]:0`, the
20 * mark of a listener whatever language the state word is in. PID 0 is none.
21 */
22export function parseNetstat(text: string, port: number): number[] {
23 const pids: number[] = []
24 for (const line of text.split(/\r?\n/)) {
25 const [proto, local, foreign, ...rest] = line.trim().split(/\s+/)
26 if (proto?.toUpperCase() !== 'TCP' || local === undefined || foreign === undefined) continue
27 if (foreign !== '0.0.0.0:0' && foreign !== '[::]:0') continue
28 if (portOf(local) !== port) continue
29 const pid = Number(rest[rest.length - 1])
30 if (Number.isInteger(pid) && pid > 0 && !pids.includes(pid)) pids.push(pid)
31 }
32 return pids
33}
34
35/** The listeners `ss -ltnpH` printed: every line is one, named by its `users:` field when it has one. */
36export function parseSs(text: string): Holder[] {
37 return text
38 .split(/\r?\n/)
39 .filter(line => line.trim() !== '')
40 .map(line => {
41 const match = /users:\(\("([^"]+)",pid=(\d+)/.exec(line)
42 return match === null ? { name: '', pid: 0 } : { name: match[1] ?? '', pid: Number(match[2]) }
43 })
44}
45
46/** The listeners `lsof -nP -iTCP:<port> -sTCP:LISTEN` printed, under its header: COMMAND then PID. */
47export function parseLsof(text: string): Holder[] {
48 return text
49 .split(/\r?\n/)
50 .filter(line => line.trim() !== '' && !line.startsWith('COMMAND'))
51 .map(line => {
52 const [name = '', pid = '0'] = line.trim().split(/\s+/)
53 return { name, pid: Number(pid) || 0 }
54 })
55}
56
57/** The image name of `tasklist /FO CSV /NH`'s one row; '' when the PID has gone. */
58export function parseTasklist(text: string): string {
59 const match = /^"([^"]+)","\d+"/m.exec(text)
60 return match?.[1] ?? ''
61}
62
63/** The row's words for a taken port: `:6006 taken by node.exe 18244`, the holder left out when it has no name. */
64export function holderWords(port: number, holder: Holder): string {
65 const name = holder.pid === 4 && holder.name === '' ? 'System' : holder.name
66 return name === '' ? `:${port} taken` : `:${port} taken by ${name}${holder.pid > 0 ? ` ${holder.pid}` : ''}`
67}
68
69/** The listeners on `port`, or undefined when no tool to list them could start. */
70async function listeners(run: Runner, isWindows: boolean, port: number): Promise<Holder[] | undefined> {
71 if (isWindows) {
72 const netstat = await run(['netstat', '-ano']).catch(() => undefined)
73 return netstat === undefined ? undefined : parseNetstat(netstat.stdout, port).map(pid => ({ name: '', pid }))
74 }
75 // ss on Linux; where it is missing (macOS), lsof.
76 const ss = await run(['ss', '-ltnpH', `sport = :${port}`]).catch(() => undefined)
77 if (ss !== undefined && ss.exitCode === 0) return parseSs(ss.stdout)
78 const lsof = await run(['lsof', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN']).catch(() => undefined)
79 return lsof === undefined ? undefined : parseLsof(lsof.stdout)
80}
81
82/**
83 * Checks `port` before a start: any listener on any address takes it. The
84 * holder is named only when taken (`tasklist` on Windows, the listing's own
85 * name elsewhere), and only when `isNaming`, since a poll needs no name.
86 */
87export async function checkPort(run: Runner, isWindows: boolean, port: number, isNaming = true): Promise<PortCheck> {
88 const found = await listeners(run, isWindows, port)
89 if (found === undefined) return { status: 'unchecked' }
90 const [first] = found
91 if (first === undefined) return { status: 'free' }
92 if (!isNaming) return { status: 'taken', words: `:${port} taken` }
93 let holder = first
94 if (isWindows && first.pid !== 4) {
95 const tasklist = await run(['tasklist', '/FI', `PID eq ${first.pid}`, '/FO', 'CSV', '/NH']).catch(() => undefined)
96 holder = { ...first, name: tasklist === undefined ? '' : parseTasklist(tasklist.stdout) }
97 }
98 return { status: 'taken', words: holderWords(port, holder) }
99}
100hooks/servers.ts 196 lines1// The server list as the pane, the status line and the model see it: each
2// row's state, glyph, words and buttons, and the restart policy.
3
4import type { PeerEntry, RowDef, ServerRun, ServerStatus } from '../types'
5import { endWords, hhmm } from './output'
6
7/** The automatic restart cap: at most this many in the window. */
8export const RESTART_CAP = 3
9export const RESTART_WINDOW_MS = 120_000
10/** How long a running server keeps its note and error button after an automatic restart. */
11export const NOTE_MS = 600_000
12
13/** A row as drawn: its definition, this session's run of it, and another session's entry for it. */
14export type Row = {
15 def: RowDef
16 run: ServerRun | undefined
17 peer: PeerEntry | undefined
18}
19
20/** A button of the detail block, by key; its hotkey is its first letter, error → prompt's `e`. */
21export type Action = 'start' | 'stop' | 'restart' | 'error' | 'hide'
22
23export type Tone = 'dim' | 'yellow' | 'red' | 'plain'
24
25export function newRun(): ServerRun {
26 return {
27 status: 'stopped',
28 url: '',
29 startedAt: 0,
30 endedAt: 0,
31 movedFrom: 0,
32 isAfterReload: false,
33 problem: '',
34 holder: '',
35 crashes: [],
36 restarts: [],
37 isGaveUp: false,
38 death: null,
39 hasNote: false,
40 lastExit: null,
41 }
42}
43
44/** Where a row stands; a row this session never ran is stopped. */
45export function statusOf(row: Row): ServerStatus {
46 return row.run?.status ?? 'stopped'
47}
48
49/** Whether a row's server is up: starting or running. */
50export function isUp(status: ServerStatus): boolean {
51 return status === 'starting' || status === 'running'
52}
53
54/** Another session runs it, and this session does not. */
55export function isPeerRow(row: Row): boolean {
56 return row.peer !== undefined && !isUp(statusOf(row))
57}
58
59const GLYPHS: Record<ServerStatus, string> = {
60 running: '●',
61 starting: '◌',
62 stopped: '○',
63 'port taken': '!',
64 crashed: '✗',
65 exited: '–',
66}
67
68export function glyph(row: Row): string {
69 return isPeerRow(row) ? '●' : GLYPHS[statusOf(row)]
70}
71
72/** `4s`, `12m`, `1h 5m`. */
73export function span(ms: number): string {
74 const seconds = Math.max(0, Math.floor(ms / 1000))
75 if (seconds < 60) return `${seconds}s`
76 const minutes = Math.floor(seconds / 60)
77 if (minutes < 60) return `${minutes}m`
78 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
79}
80
81/** Whether a running server still shows its note and error button: until 10 minutes without dying. */
82export function showsNote(run: ServerRun | undefined, now: number): boolean {
83 return run !== undefined && run.hasNote && run.status === 'running' && run.death !== null && now - run.startedAt < NOTE_MS
84}
85
86/** Whether a run has gone 10 minutes without dying by `at`: its deaths are then behind it, and the next one counts from 1. */
87export function isQuiet(run: ServerRun, at: number): boolean {
88 return at - run.startedAt >= NOTE_MS
89}
90
91/** The crash count and first time since the person last handled the server: `14:02` or `3× since 14:02`. */
92function crashWords(run: ServerRun): string {
93 const first = run.crashes[0] ?? run.death?.at ?? 0
94 return run.crashes.length > 1 ? `${run.crashes.length}× since ${hhmm(first)}` : hhmm(first)
95}
96
97/** The row's words and their tone, as the table and the detail block show them. */
98export function rowWords(row: Row, now: number): { text: string; tone: Tone } {
99 const { def, run, peer } = row
100 if (isPeerRow(row) && peer !== undefined) return { text: `running in another session${peer.port > 0 ? ` · :${peer.port}` : ''}`, tone: 'plain' }
101 const status = statusOf(row)
102 if (def.blocked !== '' && !isUp(status)) return { text: def.blocked, tone: 'yellow' }
103 if (run === undefined) return { text: 'stopped', tone: 'dim' }
104 switch (status) {
105 case 'starting':
106 return { text: `starting… ${span(now - run.startedAt)}`, tone: 'yellow' }
107 case 'running': {
108 if (showsNote(run, now)) {
109 const restarts = run.restarts.length
110 const text = run.crashes.length > 1 ? `crashed ${crashWords(run)}` : `crashed ${crashWords(run)}, restarted (${restarts}/${RESTART_CAP})`
111 return { text, tone: 'yellow' }
112 }
113 const extras = [run.isAfterReload ? 'restarted after reload' : '', run.movedFrom > 0 ? `moved from :${run.movedFrom}` : '', run.problem]
114 .filter(extra => extra !== '')
115 return { text: [`up ${span(now - run.startedAt)}`, ...extras].join(' · '), tone: run.movedFrom > 0 || run.problem !== '' ? 'yellow' : 'plain' }
116 }
117 case 'port taken':
118 return { text: run.holder, tone: 'yellow' }
119 case 'crashed':
120 if (run.isGaveUp) return { text: `crashed ${crashWords(run)}, gave up`, tone: 'red' }
121 return { text: `crashed (${endWords(run.death ?? { code: run.lastExit, signal: null })})`, tone: 'red' }
122 case 'exited':
123 return { text: `exited ${hhmm(run.endedAt)}`, tone: 'dim' }
124 default:
125 return run.problem !== '' ? { text: run.problem, tone: 'yellow' } : { text: 'stopped', tone: 'dim' }
126 }
127}
128
129/** The error's first line, which a dead row shows: the first non-empty line of the lines the death picked; '' when it printed nothing. */
130export function errorHead(run: ServerRun | undefined): string {
131 return (run?.death?.lines ?? []).map(line => line.trim()).find(line => line !== '') ?? ''
132}
133
134/** The buttons the detail block shows for a row, in order. Another session's server has none. */
135export function actions(row: Row, now: number): Action[] {
136 if (isPeerRow(row)) return []
137 const status = statusOf(row)
138 const hide: Action[] = row.def.source === 'detected' ? ['hide'] : []
139 switch (status) {
140 case 'starting':
141 return ['stop']
142 case 'running':
143 return showsNote(row.run, now) ? ['stop', 'restart', 'error'] : ['stop', 'restart']
144 case 'crashed':
145 return ['start', 'error', ...hide]
146 default:
147 return ['start', ...hide]
148 }
149}
150
151/** The port a row shows: the URL's, else the known one; 0 for none. */
152export function portOf(row: Row): number {
153 if (isPeerRow(row)) return row.peer?.port ?? 0
154 const url = row.run?.url ?? ''
155 const match = /:(\d+)$/.exec(url)
156 return match === null ? row.def.port : Number(match[1])
157}
158
159/** The command as the person reads it. */
160export function commandText(def: RowDef): string {
161 return def.argv.filter(word => word !== '').map(word => (/\s/.test(word) ? `"${word}"` : word)).join(' ')
162}
163
164/** The status line's cut: about this many characters. */
165export const STATUS_CHARS = 60
166
167/** One server as the status line names it. */
168export type StatusEntry = { name: string; port: number; isCrashed: boolean }
169
170/**
171 * The mod's status line while any of its servers runs: `dev: web :5173 · api :8000`,
172 * a crashed one as `web crashed`. Past about 60 characters the later servers'
173 * ports drop first, then the running ones are counted; a crashed name stays.
174 * Undefined when nothing runs, which clears it.
175 */
176export function statusLine(entries: readonly StatusEntry[]): string | undefined {
177 const running = entries.filter(entry => !entry.isCrashed)
178 if (running.length === 0) return undefined
179 const crashed = entries.filter(entry => entry.isCrashed).map(entry => `${entry.name} crashed`)
180 let withPorts = running.filter(entry => entry.port > 0).length
181 for (;;) {
182 let left = withPorts
183 const parts = entries.map(entry => {
184 if (entry.isCrashed) return `${entry.name} crashed`
185 if (entry.port === 0) return entry.name
186 left -= 1
187 return left >= 0 ? `${entry.name} :${entry.port}` : entry.name
188 })
189 const line = `dev: ${parts.join(' · ')}`
190 if (line.length <= STATUS_CHARS) return line
191 if (withPorts === 0) break
192 withPorts -= 1
193 }
194 return `dev: ${[`${running.length} running`, ...crashed].join(' · ')}`
195}
196hooks/tools.ts 74 lines1// The two tools the mod offers the model: read a server's output, restart a server.
2// The mod's own hooks answer them; the model cannot start or stop a server.
3
4import type { OutputLine, ServerStatus } from '../types'
5import { currentRun } from './output'
6
7export const OUTPUT_TOOL = 'mcp__dev-server-manager__output'
8export const RESTART_TOOL = 'mcp__dev-server-manager__restart'
9
10/** The lines `output` gives by default, and the most it gives. */
11export const DEFAULT_LINES = 100
12export const MOST_LINES = 500
13/** How long `restart` waits for the new run's URL or exit. */
14export const RESTART_WAIT_MS = 30_000
15
16export const OUTPUT_SPEC = {
17 name: 'output',
18 description:
19 "Read a dev server's recent output, for the servers the dev-servers pane runs (see the Dev servers section of the system prompt). " +
20 'Answers with the server, its command, state, URL and last exit code, then the last lines of its current run (100 by default, 500 at most). ' +
21 'With server left out, it reads the one running server.',
22 inputSchema: {
23 type: 'object',
24 properties: {
25 server: { type: 'string', description: 'The server, by its name in the pane.' },
26 lines: { type: 'integer', minimum: 1, maximum: MOST_LINES, description: 'How many of the last lines to read (default 100, at most 500).' },
27 },
28 },
29 isDeferred: true,
30} as const
31
32export const RESTART_SPEC = {
33 name: 'restart',
34 description:
35 'Restart a dev server the dev-servers pane runs, or one that crashed, then wait up to 30 s for its URL and answer with its new output. ' +
36 'It cannot start a stopped server or stop one: the person does that from the pane.',
37 inputSchema: {
38 type: 'object',
39 properties: { server: { type: 'string', description: 'The server, by its name in the pane.' } },
40 required: ['server'],
41 },
42 isDeferred: true,
43} as const
44
45/** A server as the tools describe it. */
46export type ToolServer = {
47 name: string
48 command: string
49 status: ServerStatus
50 url: string
51 lastExit: number | null
52 lines: readonly OutputLine[]
53}
54
55/** The `lines` argument read at the boundary: 100 when absent or not a number, between 1 and 500. */
56export function lineCount(value: unknown): number {
57 const count = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : DEFAULT_LINES
58 return Math.min(MOST_LINES, Math.max(1, count))
59}
60
61/** `output`'s answer: the header, then the current run's last `count` lines. */
62export function outputText(server: ToolServer, count: number): string {
63 const run = currentRun(server.lines).slice(-count)
64 const header = [
65 `Server: ${server.name}`,
66 `Command: ${server.command}`,
67 `State: ${server.status}`,
68 `URL: ${server.url === '' ? 'none yet' : server.url}`,
69 `Last exit code: ${server.lastExit ?? 'none'}`,
70 ]
71 const body = run.length === 0 ? ['It has printed nothing in its current run.'] : [`Output, the last ${run.length} lines of the current run:`, ...run.map(line => line.text)]
72 return [...header, ...body].join('\n')
73}
74hooks/view.tsx 283 lines1// The dev-servers pane: the title and gear, one line per server, the picked
2// server's detail block and buttons, and its output docked below in a window
3// of the mod's own, so the title and table never scroll away.
4import type { Elements } from 'claude-code'
5
6import type { OutputLine, PaneView, PeerEntry, Rows, ServerRun } from '../types'
7import { ADD_USAGE } from './add'
8import { actions, commandText, errorHead, glyph, isPeerRow, portOf, rowWords, statusOf } from './servers'
9import type { Action, Row, Tone } from './servers'
10
11/** The longest a name column grows; longer names are cut. */
12export const NAME_COLUMNS = 12
13/** Below this many output rows the pane lets the engine scroll the whole body instead. */
14export const OUTPUT_FLOOR = 4
15
16export const EMPTY_TEXT = `Nothing to run here. Add one: ${ADD_USAGE}`
17export const HINT_TEXT = 'Pick a server to act on it and see its output.'
18const UNHIDE = 'unhide'
19
20export type PaneActions = {
21 pick: (name: string) => void
22 start: (name: string) => void
23 stop: (name: string) => void
24 restart: (name: string) => void
25 fillError: (name: string) => void
26 hide: (name: string) => void
27 unhide: (name: string) => void
28 toggleHidden: () => void
29 latest: () => void
30 settings: () => void
31}
32
33export type PaneData = {
34 rows: Rows
35 runs: Record<string, ServerRun>
36 output: Record<string, OutputLine[]>
37 view: PaneView
38 peers: PeerEntry[]
39 now: number
40 hasSettings: boolean
41 actions: PaneActions
42}
43
44const LABELS: Record<Action, string> = { start: 'start', stop: 'stop', restart: 'restart', error: 'error → prompt', hide: 'hide' }
45const HOTKEYS: Record<Action, string> = { start: 's', stop: 'x', restart: 'r', error: 'e', hide: 'h' }
46
47/** Text cut to `width` cells with `…`. */
48export function cut(text: string, width: number): string {
49 const chars = Array.from(text)
50 if (chars.length <= width) return text
51 return width <= 0 ? '' : `${chars.slice(0, width - 1).join('')}…`
52}
53
54function pad(text: string, width: number): string {
55 const chars = Array.from(text)
56 return chars.length >= width ? chars.slice(0, width).join('') : text + ' '.repeat(width - chars.length)
57}
58
59/** The rows the pane lists: this project's rows, hidden ones only while unfolded, then other sessions' servers it has no row for. */
60export function paneRows(data: Pick<PaneData, 'rows' | 'runs' | 'peers' | 'view'>): { shown: Row[]; hidden: string[] } {
61 const { rows, runs, peers, view } = data
62 const peerOf = (name: string) => peers.find(peer => peer.name === name)
63 const hidden = rows.defs.filter(def => def.source === 'detected' && rows.hidden.includes(def.name)).map(def => def.name)
64 const shown: Row[] = rows.defs
65 .filter(def => view.isShowingHidden || !hidden.includes(def.name))
66 .map(def => ({ def, run: runs[def.name], peer: peerOf(def.name) }))
67 for (const peer of peers) {
68 if (rows.defs.some(def => def.name === peer.name)) continue
69 shown.push({ def: { name: peer.name, source: 'added', argv: [peer.command], cwd: '', port: peer.port, blocked: '' }, run: undefined, peer })
70 }
71 return { shown, hidden }
72}
73
74/** The words a table row shows: a dead row adds its error's first line. */
75function tableWords(row: Row, now: number): { text: string; tone: Tone } {
76 const words = rowWords(row, now)
77 const head = statusOf(row) === 'crashed' && !isPeerRow(row) ? errorHead(row.run) : ''
78 return head === '' ? words : { ...words, text: `${words.text} · ${head}` }
79}
80
81function toneProps(tone: Tone): { color?: string; dimColor?: boolean } {
82 if (tone === 'dim') return { dimColor: true }
83 if (tone === 'yellow') return { color: 'warning' }
84 if (tone === 'red') return { color: 'error' }
85 return {}
86}
87
88/** How many rows a text takes wrapped at `width`. */
89function rowsOf(text: string, width: number): number {
90 return Math.max(1, Math.ceil(Array.from(text).length / Math.max(1, width)))
91}
92
93/** The output lines drawn in a window of `rows` rows: the tail while following, else from the pinned line. */
94export function outputWindow(lines: readonly OutputLine[], rows: number, anchor: number | null): { drawn: OutputLine[]; isPinned: boolean } {
95 if (anchor !== null) {
96 const from = lines.findIndex(line => line.seq >= anchor)
97 if (from >= 0 && from + rows < lines.length) return { drawn: lines.slice(from, from + rows - 1), isPinned: true }
98 }
99 return { drawn: lines.slice(Math.max(0, lines.length - rows)), isPinned: false }
100}
101
102/** Where the output sits: the rows above it, its own rows, whether it is bounded, and the picked server's lines. */
103export type Geometry = { used: number; outputRows: number; isBounded: boolean; lines: OutputLine[] }
104
105/**
106 * The pane's rows above the output (title, table, hidden line, hint or detail
107 * block), the output box's height (one row under the body, so the engine has
108 * nothing to scroll and keeps its window at the top), and whether it is at
109 * least the floor; below it the engine scrolls the whole body.
110 */
111export function paneGeometry(data: Omit<PaneData, 'actions' | 'hasSettings'>, columns: number, bodyRows: number): Geometry {
112 const width = Math.max(20, columns)
113 const { shown, hidden } = paneRows(data)
114 const picked = shown.find(row => row.def.name === data.view.picked)
115 const empty = shown.length === 0 && hidden.length === 0
116 let used = 1 + shown.length + (hidden.length > 0 ? 1 : 0) + (empty ? rowsOf(EMPTY_TEXT, width) : 0)
117 let lines: OutputLine[] = []
118 if (picked === undefined) {
119 if (!empty) used += rowsOf(HINT_TEXT, width)
120 } else {
121 const isPeer = isPeerRow(picked)
122 const url = isPeer ? picked.peer?.url ?? '' : picked.run?.url ?? ''
123 const head = !isPeer && statusOf(picked) === 'crashed' ? errorHead(picked.run) : ''
124 used += 4 + (url !== '' ? 1 : 0) + (head !== '' ? 1 : 0) + rowsOf(rowWords(picked, data.now).text, width) + (data.view.isFillRefused ? 1 : 0)
125 lines = data.output[picked.def.name] ?? []
126 }
127 const outputRows = bodyRows - 1 - used
128 return { used, outputRows, isBounded: picked !== undefined && outputRows >= OUTPUT_FLOOR, lines }
129}
130
131/** A move of the person's, in the pane's own terms: lines, a page of the box, or the first line or the end. */
132export type Move = { lines: number } | { pages: number } | 'first' | 'end'
133
134/**
135 * What a `ui.scroll` move means for the output box. The engine sizes page keys
136 * by its own body (`bodyRows`) and Home and End by the whole tree (`contentRows`),
137 * which the bounded pane keeps one row under the body.
138 */
139export function moveOf(by: number, bodyRows: number, contentRows: number): Move {
140 if (contentRows !== bodyRows && Math.abs(by) === contentRows) return by < 0 ? 'first' : 'end'
141 if (Math.abs(by) >= bodyRows) return { pages: Math.sign(by) }
142 return { lines: by }
143}
144
145/** Where the output's view goes after a move: the seq of its first drawn line, or null to follow the tail again. */
146export function scrollAnchor(lines: readonly OutputLine[], rows: number, anchor: number | null, move: Move): number | null {
147 const last = Math.max(0, lines.length - rows)
148 const pinnedAt = anchor === null ? -1 : lines.findIndex(line => line.seq >= anchor)
149 const first = pinnedAt < 0 ? last : pinnedAt
150 let next: number
151 if (move === 'first') next = 0
152 else if (move === 'end') next = last
153 else if ('pages' in move) next = first + move.pages * Math.max(1, rows - 1)
154 else next = first + move.lines
155 next = Math.max(0, Math.min(last, next))
156 return next >= last ? null : lines[next]?.seq ?? null
157}
158
159/** The elements the pane draws with, as `$.ui.resolve(e)` gives them on every surface. */
160export type Kit = Pick<Elements['mobile'], 'Box' | 'Text' | 'Button' | 'Link'>
161
162/** The pane's tree for a body `columns` across and `bodyRows` down. */
163export function renderPane(kit: Kit, columns: number, bodyRows: number, data: PaneData) {
164 const { Box, Text, Button, Link } = kit
165 const { view, now, actions: act } = data
166 const width = Math.max(20, columns)
167 const { shown, hidden } = paneRows(data)
168 const nameWidth = Math.min(NAME_COLUMNS, Math.max(0, ...shown.map(row => Array.from(row.def.name).length)))
169 const portWidth = Math.max(0, ...shown.map(row => (portOf(row) > 0 ? `:${portOf(row)}`.length : 0)))
170 const picked = shown.find(row => row.def.name === view.picked)
171
172 const geometry = paneGeometry(data, columns, bodyRows)
173 const table = shown.map(row => {
174 const name = row.def.name
175 const port = portOf(row) > 0 ? `:${portOf(row)}` : ''
176 const lead = `${row === picked ? '▸' : ' '}${glyph(row)} `
177 const middle = ` ${pad(port, portWidth)} `
178 const words = tableWords(row, now)
179 // An unfolded hidden row ends in its own unhide button.
180 const isHidden = hidden.includes(name)
181 const room = width - Array.from(lead).length - nameWidth - Array.from(middle).length - (isHidden ? UNHIDE.length + 1 : 0)
182 return (
183 <Box key={`line-${name}`} flexDirection="row">
184 <Text>{lead}</Text>
185 <Button key={`row-${name}`} plain label={pad(name, nameWidth)} onPress={() => act.pick(name)} />
186 <Box key={`port-${name}`}><Text>{middle}</Text></Box>
187 <Box key={`words-${name}`}><Text wrap="truncate-end" {...toneProps(words.tone)}>{cut(words.text, room)}</Text></Box>
188 {isHidden && <Text> </Text>}
189 {isHidden && <Button key={`unhide-${name}`} plain dimColor label={UNHIDE} onPress={() => act.unhide(name)} />}
190 </Box>
191 )
192 })
193
194 const hiddenLine = hidden.length > 0 && (
195 <Button
196 key="hidden"
197 plain
198 dimColor
199 label={cut(`${hidden.length} hidden: ${hidden.join(', ')}`, width)}
200 onPress={() => act.toggleHidden()}
201 />
202 )
203 const empty = shown.length === 0 && hidden.length === 0
204
205 let detail = null
206 if (picked !== undefined) {
207 const name = picked.def.name
208 const run = picked.run
209 const isPeer = isPeerRow(picked)
210 const url = isPeer ? picked.peer?.url ?? '' : run?.url ?? ''
211 const status = statusOf(picked)
212 const head = !isPeer && status === 'crashed' ? errorHead(run) : ''
213 const words = rowWords(picked, now)
214 const buttons = actions(picked, now)
215 const rule = '─'.repeat(width)
216 const hiddenRow = picked.def.source === 'detected' && data.rows.hidden.includes(name)
217 detail = (
218 <Box key="detail" flexDirection="column">
219 <Text dimColor>{rule}</Text>
220 <Box flexDirection="row" columnGap={2}>
221 <Text bold>{name}</Text>
222 <Box key="command"><Text dimColor wrap="truncate-end">{commandText(picked.def)}</Text></Box>
223 </Box>
224 {url !== '' && <Link key="url" href={url} label={url.replace(/^https?:\/\//, '')} />}
225 {head !== '' && <Box key="error-head"><Text color="error" wrap="truncate-end">{head}</Text></Box>}
226 <Box key="words"><Text wrap="wrap" {...toneProps(words.tone)}>{words.text}</Text></Box>
227 {view.isFillRefused && <Box key="fill-refused"><Text dimColor>can{"'"}t reach the prompt here</Text></Box>}
228 <Box flexDirection="row" columnGap={2}>
229 {hiddenRow
230 ? null
231 : buttons.map(action => (
232 <Button
233 key={action}
234 plain
235 hotkey={HOTKEYS[action]}
236 label={LABELS[action]}
237 onPress={() => {
238 if (action === 'error') act.fillError(name)
239 else act[action](name)
240 }}
241 />
242 ))}
243 </Box>
244 <Text dimColor>{rule}</Text>
245 </Box>
246 )
247 }
248
249 const { lines, outputRows, isBounded } = geometry
250 const window = isBounded ? outputWindow(lines, outputRows, view.anchor) : { drawn: lines, isPinned: false }
251 const drawLine = (line: OutputLine) => (
252 <Text
253 key={`out-${line.seq}`}
254 wrap="truncate-end"
255 {...(line.isError === true ? { color: 'error' } : line.stream === 'divider' || line.stream === 'note' ? { dimColor: true } : {})}
256 >
257 {line.text === '' ? ' ' : line.text}
258 </Text>
259 )
260
261 return (
262 <Box flexDirection="column" width={width}>
263 <Box flexDirection="row" justifyContent="space-between">
264 <Text bold>Dev servers</Text>
265 {data.hasSettings && <Button key="mod-settings" plain dimColor label="⚙️" onPress={() => act.settings()} />}
266 </Box>
267 {empty && <Box key="empty"><Text wrap="wrap">{EMPTY_TEXT}</Text></Box>}
268 {table}
269 {hiddenLine}
270 {picked === undefined && !empty && <Box key="hint"><Text dimColor wrap="wrap">{HINT_TEXT}</Text></Box>}
271 {detail}
272 {picked !== undefined && (isBounded
273 ? (
274 <Box key="output" flexDirection="column" height={outputRows} overflow="hidden">
275 {window.drawn.map(drawLine)}
276 {window.isPinned && <Button key="latest" plain dimColor label="↓ latest" onPress={() => act.latest()} />}
277 </Box>
278 )
279 : <Box key="output" flexDirection="column">{window.drawn.map(drawLine)}</Box>)}
280 </Box>
281 )
282}
283types/index.d.ts 119 lines1/**
2 * Where a server stands: `starting` until its first local URL, `port taken`
3 * when the check found a listener, `crashed` after a death the mod did not
4 * restart from, `exited` after a clean exit (0).
5 */
6export type ServerStatus = 'stopped' | 'starting' | 'running' | 'port taken' | 'crashed' | 'exited'
7
8/** Where a row comes from: a package.json script, or `/dev-servers add`. */
9export type RowSource = 'detected' | 'added'
10
11/** A row of the pane as the project defines it, before anything runs. */
12export type RowDef = {
13 name: string
14 source: RowSource
15 /** The command and its arguments; a detected row's manager is '' while lockfiles conflict. */
16 argv: string[]
17 /** Relative to the project's root; '' for the root. */
18 cwd: string
19 /** The port declared with --port, else learned from an earlier run; 0 when unknown. */
20 port: number
21 /** Why it cannot start, shown in yellow (conflicting lockfiles); '' when it can. */
22 blocked: string
23}
24
25/** The rows of the session's project, and the detected rows the person hid. */
26export type Rows = {
27 defs: RowDef[]
28 hidden: string[]
29}
30
31/** One death of a server: when, how it ended, and the lines picked as its error. */
32export type Death = {
33 at: number
34 code: number | null
35 signal: string | null
36 /** The command as the row shows it. */
37 command: string
38 /** The last lines of the run that died, ANSI stripped, cut to the error's cap. */
39 lines: string[]
40}
41
42/** A server this session runs or ran: what its row shows beside its definition. */
43export type ServerRun = {
44 status: ServerStatus
45 /** The first local URL the current run printed; '' before it. */
46 url: string
47 /** When the current run started; 0 before the first. */
48 startedAt: number
49 /** When it last ended (exited or crashed); 0 while it runs. */
50 endedAt: number
51 /** The port it was expected on when it moved to another by itself; 0 when it did not. */
52 movedFrom: number
53 /** Brought back by a fresh module after a reload killed it. */
54 isAfterReload: boolean
55 /** Yellow words: a command that could not start, `port not checked`; '' for none. */
56 problem: string
57 /** The listener holding its port, as the row names it (`node.exe 18244`); '' when unknown. */
58 holder: string
59 /** The deaths since the person last handled it, oldest first. */
60 crashes: number[]
61 /** The automatic restarts in the last two minutes, oldest first. */
62 restarts: number[]
63 /** The restart cap is spent: it stays crashed. */
64 isGaveUp: boolean
65 /** The latest death, which the error → prompt button fills; null before one. */
66 death: Death | null
67 /** The dim note and button a running server keeps after an automatic restart. */
68 hasNote: boolean
69 /** The last exit code the mod read through to; null before one. */
70 lastExit: number | null
71}
72
73/** One kept line of a server's output, or a divider between its runs. */
74export type OutputLine = {
75 /** Counts up across the session, so a pinned view keeps its place as old lines drop. */
76 seq: number
77 stream: 'stdout' | 'stderr' | 'divider' | 'note'
78 text: string
79 at: number
80 /** One of the lines a death picked as its error. */
81 isError?: boolean
82}
83
84/** The pane's own place: the picked row and where its output stands. */
85export type PaneView = {
86 picked: string | null
87 /** The seq of the first output line drawn while pinned; null while following the tail. */
88 anchor: number | null
89 isShowingHidden: boolean
90 /** The prompt box could not be reached from this surface. */
91 isFillRefused: boolean
92}
93
94/** A server another session of this project runs, as its store entry says. */
95export type PeerEntry = {
96 sessionId: string
97 name: string
98 command: string
99 port: number
100 url: string
101 /** When that session last refreshed it; ignored once 90 s old. */
102 refreshedAt: number
103}
104
105declare module 'claude-code' {
106 interface PluginState {
107 'dev-server-manager': {
108 rows: Rows
109 /** By server name: the servers this session started. */
110 runs: Record<string, ServerRun>
111 /** By server name: the last 500 lines across the session, written in batches. */
112 output: Record<string, OutputLine[]>
113 view: PaneView
114 /** Other sessions' running servers, as the last refresh read them. */
115 peers: PeerEntry[]
116 }
117 }
118}
119