SLOPSHOPPER

dev-server-manager

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

newpaneguardcommandstatusprompt
★ 1v0.1.0no licenseupdated 2026-10-09seanrobertwright/claude-mods/mods/dev-server-manager
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dev-server-manager
│ ┃ Dev servers ✕ › fix the failing auth test and add an audit log call │ ┃ Dev servers │ ┃ Nothing to run here. Add one: /dev-servers a ⏺ Read(src/auth.ts) │ ┃ [--port N] [--cwd dir] <command…> ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dev-servers │ ⎿ dev-server-manager: Dev servers pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dev servers
Dev servers Nothing to run here. Add one: /dev-servers add <name> [--port N] [--cwd dir] <command…>
README

🧩 claude-mods

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 .env or 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 Code 2.1.289+ 21 mods TypeScript strict Checks: tsc, ESLint, validate, test

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.

What is a mod?

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 asWho decides when it acts
ModTypeScript function hooks inside Claude CodeThe mod's own code
SkillInstructions the model readsThe model
Plain pluginCommands, agents or shell hooksYou, or a shell script

The mods

ModWhere it showsWhat it does
🧭 whats-nextPane in the side panelLists the next steps of your workflow, each with a prompt ready to paste
⚡ quick-replyBand above the promptOne-click replies, including the options Claude just offered, Pass, Fail and Skip for a verdict, and the next wayfinder ticket
⏳ auto-resumeBand and status lineCounts down to a rate limit's reset, then sends "continue"
🐙 github-panelPane in the side panelThe 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-panelPane in the side panelThis project's Archon workflow runs, their graphs and logs, and the approvals that wait on you, answered from the pane
📚 shelfBand above the promptNamed folders and files; one click drops a path into what you are typing
🔔 turn-chimeSound and toastTells you when a long turn ends or Claude stops to ask you something
🔒 open-file-guardQuestion dialogAsks you to close a Word, Excel or PowerPoint file before Claude uses it
🌿 branch-guardQuestion dialogAsks you before Claude commits or pushes on the default branch
🔑 env-guardQuestion dialogAsks you before Claude reads a .env or key file, and refuses when no one is there to answer
🩹 bash-quoting-rescueRefused tool callStops a shell command that does not parse, such as an unclosed quote, before any of it runs
📂 outputsPane in the side panelThe files this session made or changed, newest first; click one to open it
🔎 sourcesPane in the side panelThe files Claude read, grouped by where they came from, with a lock to the project folder
📊 hudTwo lines under the promptModel, 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-cleanupQuestion dialog and toastAfter a PR merges, /cleanup switches to the default branch, pulls and deletes the branch
🧾 pre-pr-claims-checkRefusal Claude readsRefuses 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-gateBand above the promptRuns your checks on a press and before Claude's git commit, and hands back only the failures
🔄 chrome-tab-self-healNote after a browser tool's errorWhen a Claude in Chrome tab is gone, tells Claude to fetch the current tab IDs before it tries again
🏷 session-auto-namerBand above the promptSuggests a name for the session from its first prompt; one press renames it
🧠 model-effort-presetsBand above the promptPlan and execute presets: one press switches the model and the effort together
🖥 dev-server-managerPane in the side panel, status line and toastStarts, restarts and watches the project's dev servers; a crash toasts, restarts and fills its error into the prompt
⚙ mod-settingsGear on each pane; a dialogChange 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.

🧭 whats-next

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]
  • You stay in charge. A step's prompt reaches the model only when you read it and send it yourself. Click a step to see its prompt, then paste it, paste it into a fresh session (after your prime command, if you name one), or copy it.
  • It notices when you are done. After each answered turn, a System One model is asked whether the step is finished, as your System One models setting allows, and Haiku when none answers surely enough. A finished step leaves the list. Press d to drop it yourself.
  • The run that asks is read-only. By default it gets only read commands of git and gh plus Read, Glob and Grep. git push, git config, git -c, gh api and --output are always denied.
Command or keyWhat it does
/whats-nextBring the pane to the front
/whats-next refreshAsk the skill again
rRefresh
1 to 9Show that step's prompt in the pane
p, n, cPaste the shown prompt, paste it after /clear (and the prime command, once its turn ends), or copy it
bBack to the list
dMark the active step done
SettingKeyDefaultMeaning
Skillskill/ask-seanThe skill that answers "what's next", written as you would run it: / followed by letters, digits, _, :, . or -
Most stepsmaxSteps5How many steps to ask for (1-9)
Refresh on startrefreshOnStartonAsk for a fresh list when a session starts in a git repository
Tools the headless run may useallowedToolsempty: the read-only setComma-separated permission rules for the headless run
Modelmodelempty: your defaultModel for the headless run, as an alias (haiku) or a full id
Prime commandprimeCommandempty: noneA 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 modelsmodelChoicelocal onlyWhich 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 keyjevApiKeyemptyYour 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 portlayaPort8000The 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.

  • A list you write replaces that set; it does not add to it. To widen the set, write all of it plus your additions.
  • Each rule is a tool name, optionally followed by (...). One rule that is not, such as one starting with -, discards your whole list and the read-only set is used.
  • The run can always call 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:

  • To Jev, only while System One models allows it and the folder is not marked local-only: the active step's title, reason and prompt and the last 8,000 characters of Claude's answer. Never the contents of a file the mod read itself.
  • To Laya, which keeps it on this machine: the same, fitted to Laya's small window. The step's prompt is dropped first, then the start of the answer, so the end of the answer, where Claude says whether the work is done, is kept.
  • Sure answers only. "Done" counts at a probability of 0.9 or more and "not done" at 0.1 or less. Anything between, a model that is busy or fails, or no answer within 5 s leaves the step to Haiku, as before.
  • A key it cannot use is named. While System One models allows Jev, a missing key, one that is not a key, or one TypeSafe rejects is named on a line in the pane, and the list stays. A rejected key stays off until the mod reloads.
  • A changed key takes effect once the mod reloads. Saving it in the settings dialog reloads the mod; a key changed any other way waits for the next start of Claude Code.

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.

⚡ quick-reply

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]
  • A choice's button sends its marker with its label, so the model cannot misread it.
  • A numbered report before a yes-or-no question ("Shall I commit?") is not offered as choices.
  • When the answer recommends something, the "recommend" reply is the highlighted one. Advice against something does not count.
  • The band stays out of the way while Claude is working, and after a subagent's turn.
  • A model can read the ending too. After each answered turn, a System One model is asked how the answer ends, as your System One models setting allows: whether it asks you something, whether its numbered items are choices, and which one it recommends. The band shows its own reading at once, and a sure answer that arrives within 2 s takes its place, so a numbered report closed by "push now or wait?" is not offered as choices.

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.

SettingKeyDefaultMeaning
Replies to a questionquestionReplies`Yes\Go with your recommendation\No`Shown after Claude asks something, separated by `\`; empty shows only the choices Claude offered
Replies otherwiseidleReplies`Continue\Commit and push`Shown after any other answer; empty hides the band then, except for Next ticket
Offer the next wayfinder ticketwayfinderNexttrueAfter a wayfinder turn closes a ticket or charts a map, offer Next ticket
System One modelsmodelChoicelocal onlyWhich 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 keyjevApiKeyemptyYour 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 portlayaPort8000The 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:

  • To Jev, only while System One models allows it and the folder is not marked local-only: the last 8,000 characters of Claude's answer and the labels of the choices found in it. Never the contents of a file the mod read itself.
  • To Laya, which keeps it on this machine: the answer's closing lines that fit Laya's small window, where the question is, and at most 9 labels of at most 40 characters.
  • Sure answers only. Each part of the reading counts only when the model is sure of it, at 0.9 or more (0.1 or less for "these are not choices"). Anything less, a model that is busy or fails, a reading Laya reports as cut, or no answer within 2 s leaves the band's own reading. So does a reading that arrives after your next prompt.
  • A key it cannot use is named. While System One models allows Jev, a missing key, one that is not a key, or one TypeSafe rejects is named on a dim line under the replies, until your next prompt. A rejected key stays off until the mod reloads.

Laya and a Jev key are optional: with neither, the band reads each answer as before.

⏳ auto-resume

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]
  • The countdown shows above the prompt and in the status line.
  • Send a prompt of your own and it steps aside: you took over.
  • It gives up after too many resumes in a row without a successful answer.
CommandWhat it does
/auto-resumeSay what is waiting, if anything
/auto-resume nowSend the resume now
/auto-resume cancelCancel the wait
/auto-resume in <minutes>Schedule a resume yourself (1 to 1440)
SettingKeyDefaultMeaning
Resume prompttextcontinueWhat is sent when the wait is over; empty sends continue
Grace after reset (s)graceSeconds60Extra seconds to wait past the limit's reset time (0-900)
Retry overloaded/server errorsretryOverloadedonAlso resume after an overloaded or server error, backing off from one minute
Most retries in a rowmaxRetries5Give 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

🐙 github-panel

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
  • An issue blocked by an open issue has a red line under it. Hover it to see what blocks it.
  • In a narrow pane the implement and wayfinder buttons take a line of their own.
  • The lists refresh on a timer, after a turn once they are a minute old, and on r.
  • The pull request of the branch you are on is watched: when its checks turn green or red, a toast says "Checks passed on #40" or "Checks failed on #40". The first refresh, and the first after you switch branch, only notes where the checks stand.
  • While that pull request's checks fail, its row has a 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.
  • Running /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.
  • The pinned issue's sub-issues are its tickets, each counted once: closed ones are done, open ones with an open blocker are blocked, assigned ones are claimed, and the rest are takeable. 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.
  • The pinned issue is read in the same refresh as the lists. When a refresh fails, the last section stays and the error shows.
Command or keyWhat it does
/githubBring the pane to the front and refresh it
rRefresh
allOpen the whole list on GitHub
implement, wayfinderFill the command and the issue's URL into the prompt, without sending it
fixFill a request to fix the current branch's failing checks into the prompt box
unpinDrop the pinned issue
work nextFill /wayfinder and the pinned issue's URL into the prompt, without sending it
SettingKeyDefaultMeaning
Most items per listlimit30How many open pull requests and issues to list each (1-100)
Refresh every (minutes)refreshMinutes5How often to refresh (0-120); 0 refreshes only on open, after turns and on r
Implement button fillsimplementCommand/implementThe slash command the implement button fills before the issue's URL; empty hides the button
Wayfinder button fillswayfinderCommand/wayfinderThe 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.

🏛️ archon-panel

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 
Source 10 files
hooks/register.tsx 784 lines
1import { 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}
784
hooks/add.ts 114 lines
1// `/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}
114
hooks/detect.ts 72 lines
1// 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}
72
hooks/output.ts 92 lines
1// 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}
92
hooks/peers.ts 46 lines
1// 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}
46
hooks/port.ts 100 lines
1// 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}
100
hooks/servers.ts 196 lines
1// 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}
196
hooks/tools.ts 74 lines
1// 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}
74
hooks/view.tsx 283 lines
1// 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}
283
types/index.d.ts 119 lines
1/**
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