Runs `php artisan dev` for a Laravel project and manages it from a pane: per-process logs, restart and stop controls, port override, the site link, and a…

Run a Laravel app's php artisan dev from inside Claude Code and manage it without leaving the session.
artisan-dev is a Claude Code mod. /dev starts every process php artisan dev would, opens a pane with their logs and controls, and keeps a one-line status band above the prompt while they run.
dev and dev:list.perl (preinstalled) and the site is opened with open./dev tunnel open: cloudflared on the PATH (brew install cloudflared), plus lsof and curl (preinstalled on macOS)./dev starts the processes and opens the pane. Add a subcommand to do one thing:
| Command | What it does |
|---|---|
/dev | Start everything and open the pane |
/dev start | Start everything, no pane |
/dev stop | Stop everything and clear the log |
/dev restart [name] | Restart everything, or one process by name or number |
/dev status | Show what is running |
/dev site | Open the site |
/dev ask [name] | Attach the server state and recent output to your next prompt |
/dev clear | Clear the log |
/dev errors | Toggle showing only errors and warnings |
/dev copy [name] | Copy a process log (or the all view) to the clipboard |
/dev tunnel | Show and copy the public tunnel URL, or the open tunnel's report |
/dev tunnel open | Open a public tunnel to this checkout and check its links (again: check them again) |
/dev tunnel close | Close that tunnel and start Vite again |
/dev help | List the commands |
The port comes from, in order: a live Server running on [http://host:PORT] line from the app, a --port override, SERVER_PORT in the project's .env, then Laravel's default.
Override it for the session with --port=8111, -p 8111 or port=8111 on /dev, /dev start or /dev restart. If the servers are running, they restart on the new port. port=default (or port=reset) drops the override. A port can't be combined with restarting a single process, because it applies to every process.
The override lives in the session only. It is never written to .env, and it stays in place until you reset it or the session ends.
The site opens at APP_URL from the .env of the checkout the session is in, so inside a worktree it is that worktree's site. A loopback APP_URL (localhost or 127.0.0.1) gets the real port; any other host is used as written. The band link is labelled with that host (acme.test ↗), or :port ↗ for a loopback APP_URL.
0 shows the combined log in arrival order; 1–9 pick a process. The up and down arrows move between process names only.x Stop all (or s Start all), r Restart all, t Restart one (the selected process), o Open site, n Open tunnel (or Close tunnel), k Check again (when the tunnel's links point away from it), u Copy tunnel (shown once a tunnel URL is known), c Copy log, a Ask Claude (attaches the state and recent output to your next prompt; the button then reads asked ✓ and drops it).e (or the button in the Log divider) shows only errors and warnings.+ and − open and close the pane.A line counts as an error only when it carries a severity marker where its tool puts one: a log level such as Laravel's local.ERROR: or a Pail ERROR badge, an exception class at the start of the message, or a phrase such as Fatal error, Uncaught or Vite's Internal server error:. A word like error inside a file name, path or ordinary sentence doesn't count. Matching lines raise the band's error count, turn red in the log and show a toast.
The servers run from the checkout the session is in. When Claude enters or exits a worktree, or you run /dev, the mod moves to that checkout and restarts anything running there. It moves only once the checkout has vendor/autoload.php, so a worktree that isn't provisioned yet keeps the servers where they were. While the servers run, the mod also checks that checkout every few seconds. If its branch changes, or a git worktree is added or removed, it restarts everything and shows a toast naming the cause.
Each process runs in its own process group, so stopping or restarting one kills everything it started. A process that exits with a non-zero code or is killed from outside is restarted up to five times; a process that dies within a second of starting is not retried. The budget resets after a minute of healthy running or when you press Start all. Toasts report starts, crashes, restarts and giving up.
Reloading the mod or ending the session stops the servers. Anything left over from an earlier load is cleaned up when the session starts, but only if its process group still carries this mod's marker.
/dev tunnel open (or n in the pane) opens a Cloudflare quick tunnel to the checkout the servers run from, for a browser that is not on your machine, such as a cloud test browser. It:
vite or run dev) and keeps it stopped while the tunnel is open, Restart all included, because a Vite dev server sends the browser to :5173 on a host it cannot reach;build script with the lockfile's package manager;php artisan serve on the first free port from 8100, and cloudflared tunnel --url to that port, with no Host rewrite, so the app sees the tunnel's own host;cloudflared reports a registered connection, asks its own serve for / and the page's CSS and JS as the tunnel would deliver them (the tunnel's Host, X-Forwarded-Proto: https). Nothing is looked up in public DNS, so a fresh tunnel host is never cached as missing on your machine. A new tunnel can still take about 20 seconds to resolve for its first visit from elsewhere.Copy tunnel appears only when no link on the page points away from the tunnel. Canonical and alternate links name the real site on purpose, so they never count. Otherwise the band says how many do, and /dev tunnel lists them with the fix each kind needs in the app:
| Link | Why | Fix in the app |
|---|---|---|
https://site.test/…, localhost, 127.0.0.1 | The app builds links from APP_URL | Let web requests use their own host: drop URL::useOrigin or URL::forceRootUrl for them |
http://<tunnel>/… | The app thinks the request is plain http | URL::forceScheme('https') in AppServiceProvider |
:5173 | A Vite dev server is writing public/hot | Stop Vite in this checkout |
| Any asset that does not answer 200 | The app does not serve it at that path | — |
The mod never edits .env. Fix the app, then /dev tunnel open (or k) checks again on the same URL. /dev tunnel close, the session moving to another checkout, and the session ending all close the tunnel; closing it starts Vite again when the servers are running. /clear keeps it open.
A process of your own that prints a public tunnel URL (Cloudflare, ngrok, Expose and Herd share, localtunnel, localhost.run, Serveo, Pinggy, Tailscale funnel, Dev Tunnels and similar), such as a webhook tunnel, is still detected: the mod shows a toast and offers Copy tunnel, but its links are not checked, and an open tunnel of the mod's takes its place in the button. Detection reads process output, so a tunnel started outside the dev processes, such as from Herd's own Share button, isn't seen.
| Setting | Default | Purpose |
|---|---|---|
projectDir | empty | Absolute path of the Laravel app, which the servers then never leave. Empty follows the session's checkout, worktrees included, falling back to the same folder in the main checkout until a worktree is provisioned. |
tunnelHosts | empty | Extra comma-separated domains to treat as a tunnel, for providers not built in. |
The pure logic (argument parsing, .env reading, ANSI handling, layout, exit policy, the tunnel's link check) is covered by hooks/register.test.ts:
claude plugin validate plugins/artisan-dev
claude plugin test plugins/artisan-dev
The process lifecycle (starting, stopping, restarting, and the tunnel's serve and cloudflared) isn't covered by tests, because the test kit can't mock process spawning.
MIT
hooks/register.tsx 2655 lines1import type { EngineInterface, Register, RenderChildren } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { Dev, DevEvent, Entry, Layout, Leftover, LeftoverKind, Proc, Share } from '../types'
5
6const DEFAULT_PORT = 8000
7const PANE = 'dev'
8const PLUGIN = 'artisan-dev'
9const RESEED_MS = 500
10const PANE_ROWS = 28
11const MAX_LINES = 300
12const MAX_FEED = 600
13const ALL = '*'
14const PALETTE = ['#4ade80', '#22d3ee', '#c084fc', '#facc15', '#60a5fa', '#fb7185', '#fb923c', '#a3e635', '#f472b6']
15const BADGES: Record<string, string> = {
16 INFO: 'blue',
17 NOTICE: 'cyan',
18 WARN: 'yellow',
19 WARNING: 'yellow',
20 ERROR: 'red',
21 FAIL: 'red',
22 DEBUG: 'gray',
23 INF: 'blue',
24 WRN: 'yellow',
25 ERR: 'red',
26 DBG: 'gray',
27}
28const BADGE_PATTERN = /^\s*(INFO|NOTICE|WARN|WARNING|ERROR|FAIL|DEBUG|INF|WRN|ERR|DBG)\b\s*(.*)$/
29const TIMESTAMP_PATTERN = /^\d{4}-\d\d-\d\dT[\d:.]+Z?\s+/
30const TICK_MS = 5000
31const TOAST_GAP_MS = 15_000
32const TERM_GRACE_MS = 2000
33const RESTART_DELAY_MS = 1000
34const MIN_UPTIME_MS = 1000
35const STABLE_UPTIME_MS = 60_000
36const MAX_RESTARTS = 5
37const MAX_LINE = 4000
38export const PGID_MARK = '__dev_manager_pgid__:'
39const SEVERITY_LEAD = /^\s*(?:┌\s*)?(?:\d{4}-\d\d-\d\d[T ][\d:.]+Z?\s+)?(?:\d{1,2}:\d\d:\d\d(?:\.\d+)?(?:\s?[AP]M)?\s+)?(?:\[(?![^\]]*\b(?:error|fatal|critical)\b)[^\]\n]{1,24}\]\s*)*/i
40const LEVEL_MARK = /^(?:(?:[\w-]+\.)?(?:ERROR|CRITICAL|ALERT|EMERGENCY|FATAL|FAIL|FAILED|ERR)\b|(?:✘\s*)?\[(?:ERROR|FATAL|CRITICAL)\])/
41const CLASS_MARK = /^(?:[\w\\]*\w+Exception\b|Exception\b(?=\s*[:(─])|[\w\\]*\\\w*Error\b|\w*Error\b(?=[:(]))/
42const PHRASE_MARK = /^(?:(?:PHP )?Fatal error\b|Uncaught\b|Traceback \(most recent call last\)|(?:internal server |pre-transform )?error(?: \w+){0,3}:)/i
43const ANSI_PATTERN = /\u001b\[[0-9;:?]*[ -/]*[@-~]/g
44
45const emptyDev: Dev = { status: 'stopped', procs: [], feed: [], notes: [] }
46
47const dev = atom({ plugin: 'artisan-dev', key: 'dev' } as const, emptyDev)
48const selected = atom({ plugin: 'artisan-dev', key: 'selected' } as const, null)
49const now = atom({ plugin: 'artisan-dev', key: 'now' } as const, 0)
50const isOpen = atom({ plugin: 'artisan-dev', key: 'isOpen' } as const, false)
51const override = atom({ plugin: 'artisan-dev', key: 'override' } as const, null)
52const configured = atom({ plugin: 'artisan-dev', key: 'configured' } as const, null)
53const appUrl = atom({ plugin: 'artisan-dev', key: 'appUrl' } as const, null)
54const detected = atom({ plugin: 'artisan-dev', key: 'detected' } as const, null)
55const scrollBack = atom({ plugin: 'artisan-dev', key: 'back' } as const, 0)
56const onlyErrors = atom({ plugin: 'artisan-dev', key: 'onlyErrors' } as const, false)
57const tunnel = atom({ plugin: 'artisan-dev', key: 'tunnel' } as const, null)
58const share = atom({ plugin: 'artisan-dev', key: 'share' } as const, null)
59const attached = atom({ plugin: 'artisan-dev', key: 'attached' } as const, null)
60const layout = atom({ plugin: 'artisan-dev', key: 'layout' } as const, null)
61
62const WHEEL_STEP = 3
63
64export const nextBack = (current: number, by: number, total: number): number => {
65 const step = Math.abs(by) <= WHEEL_STEP ? by * WHEEL_STEP : by
66
67 return Math.min(Math.max(0, total - 1), Math.max(0, current - step))
68}
69
70const SERVER_LINE = /Server running on \[https?:\/\/[^\]:]+:(\d+)\]/
71
72export const toPort = (value: string): number | null => {
73 const port = Number(value.trim())
74
75 return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null
76}
77
78const unquote = (value: string): string => {
79 const quoted = value.match(/^(['"])(.*?)\1(?:\s+#.*)?\s*$/)
80
81 return quoted ? (quoted[2] ?? '') : value.replace(/\s+#.*$/, '').trim()
82}
83
84export const parseEnvValue = (text: string, name: string): string | null => {
85 const pattern = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*(.*)$`)
86
87 for (const line of text.split('\n')) {
88 const match = line.match(pattern)
89
90 if (match) {
91 return unquote(match[1] ?? '')
92 }
93 }
94
95 return null
96}
97
98export const parseEnvPort = (text: string): number | null => {
99 const value = parseEnvValue(text, 'SERVER_PORT')
100
101 return value === null ? null : toPort(value)
102}
103
104const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '0.0.0.0', '[::1]']
105
106const parseSite = (appUrl: string | null): { scheme: string; host: string; rest: string } | null => {
107 const found = appUrl?.match(/^(https?:\/\/)(\[[^\]]+\]|[^/:?#]+)(:\d+)?(.*)$/i)
108
109 return found ? { scheme: found[1] ?? '', host: found[2] ?? '', rest: found[4] ?? '' } : null
110}
111
112export const siteHost = (appUrl: string | null): string | null => {
113 const host = parseSite(appUrl)?.host
114
115 return host && !LOOPBACK_HOSTS.includes(host) ? host : null
116}
117
118export const siteUrl = (appUrl: string | null, port: number | null): string => {
119 const site = parseSite(appUrl)
120
121 if (!appUrl || !site) {
122 return `http://127.0.0.1:${port ?? DEFAULT_PORT}`
123 }
124
125 return LOOPBACK_HOSTS.includes(site.host) && port ? `${site.scheme}${site.host}:${port}${site.rest}` : appUrl
126}
127
128export const DEV_ACTIONS = [
129 'start',
130 'stop',
131 'restart',
132 'status',
133 'site',
134 'ask',
135 'clear',
136 'errors',
137 'copy',
138 'tunnel',
139 'help',
140] as const
141
142export type DevAction = 'open' | (typeof DEV_ACTIONS)[number]
143
144export type DevArgs = { action: DevAction; target?: string; port?: number | null; error?: string }
145
146export const DEV_HELP = [
147 '/dev start everything and open the pane',
148 '/dev start start everything (no pane)',
149 '/dev stop stop everything and clear the log',
150 '/dev restart [name] restart everything, or one process by name or number',
151 '/dev status show what is running',
152 '/dev site open the site',
153 '/dev ask [name] attach the state and latest output to your next prompt',
154 '/dev clear clear the log',
155 '/dev errors toggle showing only errors and warnings in the log',
156 '/dev copy [name] copy a process log (or the all view) to the clipboard',
157 '/dev tunnel show and copy the public tunnel URL',
158 '/dev tunnel open open a checked tunnel to this checkout (again: check its links again)',
159 '/dev tunnel close close that tunnel and start Vite again',
160 'Add --port=8111, -p 8111 or port=8111 to start or restart on another port (port=default resets).',
161].join('\n')
162
163const PORT_ACTIONS: DevAction[] = ['open', 'start', 'restart']
164const TARGET_ACTIONS: DevAction[] = ['restart', 'ask', 'copy', 'tunnel']
165const TUNNEL_VERBS = ['open', 'close']
166
167export const parseDevArgs = (args: string): DevArgs => {
168 const tokens = args.trim().split(/\s+/).filter(Boolean)
169 const first = (tokens[0] ?? '').toLowerCase()
170 const named = DEV_ACTIONS.find(name => name === first)
171 const result: DevArgs = { action: named ?? 'open' }
172
173 for (let index = named ? 1 : 0; index < tokens.length; index++) {
174 const token = tokens[index] ?? ''
175 const inline = token.match(/^(?:--port|-p|port)=(.+)$/)
176 const separate = token === '--port' || token === '-p'
177 const value = inline ? (inline[1] ?? '') : separate ? (tokens[++index] ?? '') : null
178
179 if (value === null) {
180 if (TARGET_ACTIONS.includes(result.action) && result.target === undefined) {
181 result.target = token
182
183 continue
184 }
185
186 return { action: result.action, error: `Unknown argument "${token}".\n${DEV_HELP}` }
187 }
188
189 if (!PORT_ACTIONS.includes(result.action)) {
190 return { action: result.action, error: `A port can't be used with "/dev ${result.action}".` }
191 }
192
193 if (value === 'default' || value === 'reset') {
194 result.port = null
195 } else {
196 const port = toPort(value)
197
198 if (port === null) {
199 return { action: result.action, error: `"${value}" is not a valid port (1–65535).` }
200 }
201
202 result.port = port
203 }
204 }
205
206 if (result.action === 'tunnel' && result.target !== undefined && !TUNNEL_VERBS.includes(result.target.toLowerCase())) {
207 return { action: result.action, error: `Unknown tunnel action "${result.target}". Use /dev tunnel, /dev tunnel open or /dev tunnel close.` }
208 }
209
210 if (result.action === 'restart' && result.target !== undefined && result.port !== undefined) {
211 return { action: result.action, error: 'A port applies to every process — use /dev restart --port=N without a name.' }
212 }
213
214 return result
215}
216
217export const statusText = (name: string, current: Dev, port: number | null, url: string, at: number): string => {
218 if (current.procs.length === 0) {
219 return 'Dev servers are not running.'
220 }
221
222 const { total, up } = summarise(current)
223 const width = Math.max(...current.procs.map(proc => proc.label.length))
224 const rows = current.procs.map(proc => {
225 const age = proc.state === 'up' ? ` ${uptime(proc.startedAt, at)}` : ''
226 const errors = proc.errors > 0 ? ` ⚠${proc.errors}` : ''
227
228 return `${proc.state === 'up' ? '●' : '○'} ${proc.label.padEnd(width)} ${proc.state}${age}${errors}`
229 })
230
231 return [`${name} dev — ${current.status} · ${up}/${total} up${port ? ` · :${port}` : ''}`, url, ...rows].join('\n')
232}
233
234const pickBranch = (named: string, detached: string): string => named.trim() || detached.trim()
235
236export const parseLayout = (branch: string, head: string, porcelain: string): Layout => ({
237 branch: pickBranch(branch, head),
238 worktrees: porcelain
239 .split('\n')
240 .filter(line => line.startsWith('worktree '))
241 .map(line => line.slice('worktree '.length))
242 .sort(),
243})
244
245export const layoutChange = (last: Layout | null, next: Layout): string | null => {
246 if (!last) {
247 return null
248 }
249
250 if (last.branch !== next.branch) {
251 return `Branch changed to ${next.branch}`
252 }
253
254 return last.worktrees.join('\n') === next.worktrees.join('\n') ? null : 'Worktrees changed'
255}
256
257export const pickDir = (dirs: { pinned: string; here: string; ready: boolean; current: string; main: string }): string =>
258 dirs.pinned || (dirs.ready ? dirs.here : dirs.current || dirs.main || dirs.here)
259
260export const matchProcess = (procs: { label: string }[], target: string): string | null => {
261 if (/^\d+$/.test(target)) {
262 return procs[Number(target) - 1]?.label ?? null
263 }
264
265 return procs.find(proc => proc.label.toLowerCase() === target.toLowerCase())?.label ?? null
266}
267
268const PROBLEM_LEVELS = new Set(['WARN', 'WARNING', 'WRN', 'ERROR', 'ERR', 'FAIL'])
269
270export const hasError = (text: string): boolean => {
271 const rest = text.slice(text.match(SEVERITY_LEAD)?.[0].length ?? 0)
272
273 return LEVEL_MARK.test(rest) || CLASS_MARK.test(rest) || PHRASE_MARK.test(rest)
274}
275
276export const isProblem = (raw: string): boolean => {
277 const plain = stripAnsi(raw).replace(TIMESTAMP_PATTERN, '')
278
279 if (plain.startsWith('·')) {
280 return true
281 }
282
283 const badge = plain.match(BADGE_PATTERN)
284
285 return hasError(plain) || (badge !== null && PROBLEM_LEVELS.has(badge[1] ?? ''))
286}
287
288export const visibleEntries = (current: Dev, pick: string, onlyProblems: boolean): Entry[] => {
289 const shown = current.procs.find(proc => proc.label === pick)
290 const entries: Entry[] = shown
291 ? shown.lines.map(text => ({ label: shown.label, text, at: 0 }))
292 : current.feed
293
294 return onlyProblems ? entries.filter(entry => isProblem(entry.text)) : entries
295}
296
297export const logText = (entries: Entry[], withLabel: boolean): string => {
298 const width = Math.max(0, ...entries.map(entry => entry.label.length))
299
300 return entries
301 .map(entry => {
302 const text = stripAnsi(entry.text)
303
304 return withLabel ? `${timeOf(entry.at)} ${entry.label.padEnd(width)} ${text}` : text
305 })
306 .join('\n')
307}
308
309const TUNNEL_SUFFIXES = [
310 'trycloudflare.com',
311 'cfargotunnel.com',
312 'ngrok-free.app',
313 'ngrok-free.dev',
314 'ngrok.app',
315 'ngrok.dev',
316 'ngrok.io',
317 'sharedwithexpose.com',
318 'loca.lt',
319 'localtunnel.me',
320 'lhr.life',
321 'localhost.run',
322 'serveo.net',
323 'pinggy.io',
324 'pinggy.link',
325 'tunnelmole.com',
326 'tunnelmole.net',
327 'devtunnels.ms',
328 'ts.net',
329 'loophole.site',
330 'bore.pub',
331]
332const TUNNEL_IGNORE = /map\[|environmental variables|settings:/i
333const URL_IN_LINE = /https?:\/\/[^\s|)\]'"<>,]+/gi
334
335export const parseHosts = (value: string): string[] =>
336 value
337 .split(/[,\s]+/)
338 .map(host => host.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/[/:?#].*$/, ''))
339 .filter(Boolean)
340
341export const findTunnelUrl = (raw: string, extraHosts: string[] = []): string | null => {
342 const text = stripAnsi(raw)
343
344 if (TUNNEL_IGNORE.test(text)) {
345 return null
346 }
347
348 for (const match of text.matchAll(URL_IN_LINE)) {
349 const url = match[0].replace(/[.,;:]+$/, '')
350 const host = (url.replace(/^https?:\/\//i, '').split(/[/:?#]/)[0] ?? '').toLowerCase()
351 const isBuiltIn = TUNNEL_SUFFIXES.some(suffix => host.endsWith(`.${suffix}`))
352 const isExtra = extraHosts.some(extra => host === extra || host.endsWith(`.${extra}`))
353
354 if (isBuiltIn || isExtra) {
355 return url
356 }
357 }
358
359 return null
360}
361
362export const hostOf = (url: string): string => (url.replace(/^https?:\/\//i, '').split(/[/:?#]/)[0] ?? '').toLowerCase()
363
364const LINK_ATTRIBUTE = /\b(?:href|src|action)\s*=\s*["']([^"']+)["']/gi
365const LOCAL_HOST = /^(?:https?:)?\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|[^/:?#]+\.(?:test|localhost))(?=[/:?#]|$)/i
366const DEV_SERVER_PORT = /^(?:https?:)?\/\/[^/?#]+:5173(?=[/?#]|$)/i
367const ASSET_PATH = /\.(?:css|js|mjs)(?:[?#]|$)/i
368const MAX_LEFTOVERS = 50
369
370const HTML_TAG = /<[a-z][^>]*>/gi
371const SITE_IDENTITY_LINK = /^<link\b[^>]*\brel\s*=\s*["'][^"']*\b(?:canonical|alternate)\b/i
372
373const pageLinks = (html: string): string[] =>
374 [...html.matchAll(HTML_TAG)]
375 .map(match => match[0])
376 .filter(tag => !SITE_IDENTITY_LINK.test(tag))
377 .flatMap(tag => [...tag.matchAll(LINK_ATTRIBUTE)].map(match => (match[1] ?? '').replaceAll('&', '&').trim()))
378
379export const findLeftovers = (html: string, tunnelHost: string): Leftover[] =>
380 pageLinks(html)
381 .flatMap((url): Leftover[] => {
382 if (DEV_SERVER_PORT.test(url)) {
383 return [{ kind: 'vite', url }]
384 }
385
386 if (LOCAL_HOST.test(url)) {
387 return [{ kind: 'local-host', url }]
388 }
389
390 return hostOf(url) === tunnelHost && /^http:\/\//i.test(url) ? [{ kind: 'plain-http', url }] : []
391 })
392 .slice(0, MAX_LEFTOVERS)
393
394export const assetUrls = (html: string, tunnelUrl: string): string[] => {
395 const origin = tunnelUrl.replace(/\/+$/, '')
396 const host = hostOf(tunnelUrl)
397 const urls = pageLinks(html)
398 .filter(url => ASSET_PATH.test(url))
399 .map(url => (url.startsWith('/') && !url.startsWith('//') ? `${origin}${url}` : url))
400 .filter(url => /^https:\/\//i.test(url) && hostOf(url) === host)
401
402 return [...new Set(urls)]
403}
404
405export const LEFTOVER_FIXES: Record<LeftoverKind, string> = {
406 'local-host': 'The app builds links from APP_URL. Let web requests use their own host (look for URL::useOrigin or URL::forceRootUrl).',
407 'plain-http': "The app thinks the request is http. Add URL::forceScheme('https') in AppServiceProvider.",
408 vite: 'A Vite dev server is writing public/hot. Stop Vite in this checkout.',
409 broken: 'These assets do not load through the tunnel.',
410}
411
412const FIRST_VISIT = 'A new tunnel can take about 20 seconds to resolve for the first visit.'
413
414export const shareReport = (current: Share): string => {
415 if (current.state === 'failed') {
416 return `Tunnel failed: ${current.note ?? 'unknown error'}`
417 }
418
419 if (current.state === 'starting' || current.state === 'checking') {
420 return `Tunnel is ${current.state === 'starting' ? 'starting' : 'checking its links'}…${current.note ? ` (${current.note})` : ''}`
421 }
422
423 if (current.state === 'open') {
424 return `Tunnel: ${current.url} (links checked)\n${FIRST_VISIT}`
425 }
426
427 const kinds = [...new Set(current.leftovers.map(leftover => leftover.kind))]
428 const groups = kinds.map(kind => {
429 const urls = current.leftovers.filter(leftover => leftover.kind === kind).map(leftover => leftover.url)
430 const more = urls.length > 1 ? ` (+${urls.length - 1})` : ''
431
432 return ` ✗ ${urls[0]}${more}\n ${LEFTOVER_FIXES[kind]}`
433 })
434 const count = current.leftovers.length
435 const summary = count > 0 ? `${count} ${count === 1 ? 'link points' : 'links point'} away from the tunnel:` : (current.note ?? 'The page did not load.')
436
437 return [`Tunnel: ${current.url}`, summary, ...groups, 'Fix the app, then /dev tunnel open checks again.'].join('\n')
438}
439
440export const shareBand = (current: Share): { text: string; color: string } => {
441 if (current.state === 'open') {
442 return { text: ' · ⇄ tunnel ok', color: 'green' }
443 }
444
445 if (current.state === 'failed') {
446 return { text: ' · ⇄ tunnel failed', color: 'red' }
447 }
448
449 if (current.state === 'broken') {
450 const count = current.leftovers.length
451
452 return { text: count > 0 ? ` · ⇄ tunnel: ${count} ${count === 1 ? 'link points' : 'links point'} away` : ' · ⇄ tunnel: page did not load', color: 'yellow' }
453 }
454
455 return { text: ` · ⇄ tunnel: ${current.state === 'checking' ? 'checking links' : (current.note ?? 'starting')}…`, color: 'yellow' }
456}
457
458export const isViteCommand =(command: string): boolean => /\bvite\b|\brun\s+dev\b/.test(command)
459
460const BUILD_RUNNERS: [string, string][] = [
461 ['bun.lock', 'bun'],
462 ['bun.lockb', 'bun'],
463 ['pnpm-lock.yaml', 'pnpm'],
464 ['yarn.lock', 'yarn'],
465 ['package-lock.json', 'npm'],
466]
467
468export const BUILD_LOCKS = BUILD_RUNNERS.map(([lock]) => lock)
469
470export const buildArgv = (packageJson: string, locks: string[]): string[] | null => {
471 let scripts: Record<string, unknown> = {}
472
473 try {
474 scripts = (JSON.parse(packageJson) as { scripts?: Record<string, unknown> }).scripts ?? {}
475 } catch {
476 return null
477 }
478
479 if (typeof scripts.build !== 'string') {
480 return null
481 }
482
483 const runner = BUILD_RUNNERS.find(([lock]) => locks.includes(lock))?.[1] ?? 'npm'
484
485 return [runner, 'run', 'build']
486}
487
488export const effectivePort = (
489 ports: { override: number | null; configured: number | null; detected: number | null },
490): number | null => ports.detected ?? ports.override ?? ports.configured
491
492const OSC_PATTERN = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g
493const CSI_PATTERN = /\u001b\[([0-9;:?]*)([ -/]*[@-~])/g
494
495export const stripAnsi = (text: string): string =>
496 text.replace(OSC_PATTERN, '').replace(ANSI_PATTERN, '').replace(/[\u001b\u0007]/g, '')
497
498export const hasAnsi = (text: string): boolean => text.includes('\u001b')
499
500export type Style = {
501 color?: string
502 background?: string
503 bold?: boolean
504 dim?: boolean
505 italic?: boolean
506 underline?: boolean
507 strike?: boolean
508 inverse?: boolean
509}
510
511export type Segment = { text: string; style: Style }
512
513const BASIC = ['gray', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
514const BRIGHT = ['#9ca3af', '#fca5a5', '#86efac', '#fde047', '#93c5fd', '#d8b4fe', '#67e8f9', '#ffffff']
515const CUBE = [0, 95, 135, 175, 215, 255]
516
517const rgb = (r: number, g: number, b: number): string =>
518 `#${[r, g, b].map(value => Math.min(255, Math.max(0, value)).toString(16).padStart(2, '0')).join('')}`
519
520export const color256 = (n: number): string | undefined => {
521 if (n < 0 || n > 255) {
522 return undefined
523 }
524
525 if (n < 8) {
526 return BASIC[n]
527 }
528
529 if (n < 16) {
530 return BRIGHT[n - 8]
531 }
532
533 if (n < 232) {
534 const index = n - 16
535
536 return rgb(CUBE[Math.floor(index / 36)] ?? 0, CUBE[Math.floor(index / 6) % 6] ?? 0, CUBE[index % 6] ?? 0)
537 }
538
539 const level = 8 + (n - 232) * 10
540
541 return rgb(level, level, level)
542}
543
544export const applySgr = (style: Style, codes: number[]): Style => {
545 let next: Style = { ...style }
546 const list = codes.length === 0 ? [0] : codes
547
548 for (let index = 0; index < list.length; index++) {
549 const code = list[index] ?? 0
550
551 if (code === 0) {
552 next = {}
553 } else if (code === 1) {
554 next.bold = true
555 } else if (code === 2) {
556 next.dim = true
557 } else if (code === 3) {
558 next.italic = true
559 } else if (code === 4) {
560 next.underline = true
561 } else if (code === 7) {
562 next.inverse = true
563 } else if (code === 9) {
564 next.strike = true
565 } else if (code === 22) {
566 next.bold = false
567 next.dim = false
568 } else if (code === 23) {
569 next.italic = false
570 } else if (code === 24) {
571 next.underline = false
572 } else if (code === 27) {
573 next.inverse = false
574 } else if (code === 29) {
575 next.strike = false
576 } else if (code >= 30 && code <= 37) {
577 next.color = BASIC[code - 30]
578 } else if (code >= 90 && code <= 97) {
579 next.color = BRIGHT[code - 90]
580 } else if (code >= 40 && code <= 47) {
581 next.background = BASIC[code - 40]
582 } else if (code >= 100 && code <= 107) {
583 next.background = BRIGHT[code - 100]
584 } else if (code === 39) {
585 next.color = undefined
586 } else if (code === 49) {
587 next.background = undefined
588 } else if (code === 38 || code === 48) {
589 const mode = list[index + 1]
590 let value: string | undefined
591
592 if (mode === 5) {
593 value = color256(list[index + 2] ?? -1)
594 index += 2
595 } else if (mode === 2) {
596 value = rgb(list[index + 2] ?? 0, list[index + 3] ?? 0, list[index + 4] ?? 0)
597 index += 4
598 }
599
600 if (code === 38) {
601 next.color = value
602 } else {
603 next.background = value
604 }
605 }
606 }
607
608 return next
609}
610
611export const parseAnsi = (text: string): Segment[] => {
612 const cleaned = text.replace(OSC_PATTERN, '')
613 const segments: Segment[] = []
614 let style: Style = {}
615 let last = 0
616
617 const push = (chunk: string) => {
618 const plain = chunk.replace(/[\u001b\u0007]/g, '')
619
620 if (plain) {
621 segments.push({ text: plain, style })
622 }
623 }
624
625 for (const match of cleaned.matchAll(CSI_PATTERN)) {
626 const index = match.index ?? 0
627
628 push(cleaned.slice(last, index))
629 last = index + match[0].length
630
631 if (match[2] === 'm') {
632 style = applySgr(
633 style,
634 (match[1] ?? '')
635 .split(/[;:]/)
636 .filter(part => part !== '')
637 .map(Number),
638 )
639 }
640 }
641
642 push(cleaned.slice(last))
643
644 return segments
645}
646
647export const wrapRanges = (text: string, width: number): [number, number][] => {
648 const ranges: [number, number][] = []
649 let start = 0
650
651 while (text.length - start > width) {
652 const space = text.lastIndexOf(' ', start + width)
653 const end = space > start ? space : start + width
654
655 ranges.push([start, end])
656 start = end
657
658 while (start < text.length && /\s/.test(text[start] ?? '')) {
659 start++
660 }
661 }
662
663 ranges.push([start, text.length])
664
665 return ranges
666}
667
668export const wrapSegments = (segments: Segment[], width: number): Segment[][] => {
669 const plain = segments.map(segment => segment.text).join('')
670 const styles: Style[] = segments.flatMap(segment => Array.from({ length: segment.text.length }, () => segment.style))
671
672 return wrapRanges(plain, width).map(([from, to]) => {
673 const row: Segment[] = []
674
675 for (let index = from; index < to; index++) {
676 const style = styles[index] ?? {}
677 const tail = row[row.length - 1]
678
679 if (tail && tail.style === style) {
680 tail.text += plain[index] ?? ''
681 } else {
682 row.push({ text: plain[index] ?? '', style })
683 }
684 }
685
686 return row
687 })
688}
689
690export const splitLines = (buffer: string, chunk: string): { lines: string[]; rest: string } => {
691 const parts = (buffer + chunk).split('\n')
692
693 return { lines: parts.slice(0, -1), rest: parts[parts.length - 1] ?? '' }
694}
695
696export const uptime = (since: number, at: number): string => {
697 const seconds = Math.max(0, Math.floor((at - since) / 1000))
698
699 if (seconds < 60) {
700 return `${seconds}s`
701 }
702
703 if (seconds < 3600) {
704 return `${Math.floor(seconds / 60)}m`
705 }
706
707 return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
708}
709
710const withProc = (procs: Proc[], label: string, change: (proc: Proc) => Proc): Proc[] =>
711 procs.map(proc => (proc.label === label ? change(proc) : proc))
712
713export const applyEvent = (current: Dev, event: DevEvent): Dev => {
714 const label = event.label
715 const at = Date.parse(event.time ?? '') || 0
716
717 if (!label) {
718 return current
719 }
720
721 const known = current.procs.some(proc => proc.label === label)
722
723 if (event.type === 'start') {
724 const fresh: Proc = {
725 label,
726 command: event.command ?? '',
727 color: event.color ?? '',
728 pid: event.pid ?? 0,
729 state: 'up',
730 startedAt: at,
731 errors: 0,
732 lines: [],
733 }
734
735 return {
736 ...current,
737 procs: known
738 ? withProc(current.procs, label, proc => ({
739 ...proc,
740 command: event.command ?? proc.command,
741 color: event.color ?? proc.color,
742 pid: event.pid ?? proc.pid,
743 state: 'up',
744 startedAt: at,
745 errors: 0,
746 }))
747 : [...current.procs, fresh],
748 }
749 }
750
751 if (!known) {
752 return current
753 }
754
755 if (event.type === 'pid') {
756 return { ...current, procs: withProc(current.procs, label, proc => ({ ...proc, pid: event.pid ?? proc.pid })) }
757 }
758
759 if (event.type === 'output') {
760 const raw = (event.text ?? '').trimEnd()
761 const text = stripAnsi(raw)
762
763 if (!text.trim()) {
764 return current
765 }
766
767 return {
768 ...current,
769 feed: [...current.feed, { label, text: raw, at }].slice(-MAX_FEED),
770 procs: withProc(current.procs, label, proc => ({
771 ...proc,
772 errors: proc.errors + (!event.quiet && hasError(text) ? 1 : 0),
773 lines: [...proc.lines, raw].slice(-MAX_LINES),
774 })),
775 }
776 }
777
778 const detail = event.code ?? event.signal
779 const note = `· ${event.type}${detail === undefined || detail === null ? '' : ` (${detail})`}`
780
781 return {
782 ...current,
783 feed: [...current.feed, { label, text: note, at }].slice(-MAX_FEED),
784 procs: withProc(current.procs, label, proc => ({
785 ...proc,
786 state:
787 event.type === 'exit' || event.type === 'failed'
788 ? 'exited'
789 : event.type === 'restarting'
790 ? 'restarting'
791 : proc.state,
792 lines: [...proc.lines, note].slice(-MAX_LINES),
793 })),
794 }
795}
796
797export const summarise = (current: Dev): { total: number; up: number; errors: number } => ({
798 total: current.procs.length,
799 up: current.procs.filter(proc => proc.state === 'up').length,
800 errors: current.procs.reduce((sum, proc) => sum + proc.errors, 0),
801})
802
803const procTail = (proc: Proc): string =>
804 `[${proc.label}] \`${proc.command}\`\n${proc.lines.slice(-25).map(stripAnsi).join('\n')}`
805
806const logBlock = (current: Dev, label: string | null): string => {
807 const failing = current.procs.filter(proc => proc.errors > 0)
808 const picked = label ? current.procs.filter(proc => proc.label === label) : failing
809
810 return picked.length > 0 ? picked.map(procTail).join('\n\n') : label ? '' : logText(current.feed.slice(-25), true)
811}
812
813export const promptText = (name: string, current: Dev, label: string | null, status: string): string => {
814 const body = logBlock(current, label)
815 const log = body ? `\n\n\`\`\`\n${body}\n\`\`\`` : ''
816
817 return `State and latest output of \`php artisan dev\` in ${name}.\n\n${status}${log}\n`
818}
819
820export type ProcSpec = { label: string; command: string; color: string }
821
822export const parseDevList = (output: string): ProcSpec[] => {
823 const start = output.indexOf('[')
824
825 if (start < 0) {
826 return []
827 }
828
829 const parsed = JSON.parse(output.slice(start)) as { name?: string; command?: string; color?: string }[]
830
831 return parsed
832 .filter(entry => entry.name && entry.command)
833 .map(entry => ({ label: entry.name ?? '', command: entry.command ?? '', color: entry.color ?? '' }))
834}
835
836export const spawnArgv = (command: string): string[] => [
837 'perl',
838 '-MPOSIX',
839 '-e',
840 'POSIX::setsid(); exec @ARGV',
841 'sh',
842 '-c',
843 `echo ${PGID_MARK}$$; ${command}`,
844]
845
846export const afterCarriage = (line: string): string => line.split('\r').filter(Boolean).pop() ?? ''
847
848export type ExitVerdict = {
849 outcome: 'stopped' | 'exited' | 'start-failed' | 'gave-up' | 'restart'
850 attempts: number
851}
852
853export const judgeExit = (exit: { wasStopped: boolean; code: number | null; ranFor: number; attempts: number }): ExitVerdict => {
854 if (exit.wasStopped) {
855 return { outcome: 'stopped', attempts: 0 }
856 }
857
858 if (exit.code === 0) {
859 return { outcome: 'exited', attempts: 0 }
860 }
861
862 const attempts = (exit.ranFor >= STABLE_UPTIME_MS ? 0 : exit.attempts) + 1
863
864 if (exit.ranFor < MIN_UPTIME_MS) {
865 return { outcome: 'start-failed', attempts }
866 }
867
868 return { outcome: attempts > MAX_RESTARTS ? 'gave-up' : 'restart', attempts }
869}
870
871type Child = ReturnType<EngineInterface['process']['spawn']>
872
873type Runner = ProcSpec & {
874 run: Child | null
875 pgid: number | null
876 starting: boolean
877 exiting: boolean
878 stopping: boolean
879 attempts: number
880 timer: { cancel: () => void } | null
881 done: Promise<void>
882 finish: () => void
883}
884
885const runners = new Map<string, Runner>()
886const lastToast: Record<string, number> = {}
887let extraHosts: string[] = []
888let project = { dir: '', name: '' }
889let pinnedDir = ''
890let mainDir = ''
891let siteDir = ''
892let rewatch = false
893let tick: { cancel: () => void } | null = null
894let watching = false
895let view = { columns: 80, labelWidth: 6 }
896
897type ShareProc = { label: string; run: Child; pgid: number | null; stopping: boolean; tail: string[]; done: Promise<void> }
898
899let shareProcs: ShareProc[] = []
900let shareTurn = 0
901let isViteHeld = false
902
903const newRunner = (spec: ProcSpec): Runner => ({
904 ...spec,
905 run: null,
906 pgid: null,
907 starting: false,
908 exiting: false,
909 stopping: false,
910 attempts: 0,
911 timer: null,
912 done: Promise.resolve(),
913 finish: () => {},
914})
915
916const isActive = (runner: Runner): boolean =>
917 runner.run !== null || runner.timer !== null || runner.starting || runner.exiting
918
919const anyActive = (): boolean => [...runners.values()].some(isActive)
920
921const addsEntry = (event: DevEvent): boolean =>
922 event.type !== 'start' && event.type !== 'pid' && (event.type !== 'output' || stripAnsi(event.text ?? '').trim() !== '')
923
924async function observe($: EngineInterface, event: DevEvent, at: number) {
925 if (event.type !== 'output' || event.quiet) {
926 return
927 }
928
929 const label = event.label ?? ''
930 const raw = event.text ?? ''
931 const served = stripAnsi(raw).match(SERVER_LINE)
932
933 if (served) {
934 const before = await read($, detected)
935 const port = toPort(served[1] ?? '')
936
937 await update($, detected, () => port)
938
939 if (before === null && port !== null) {
940 $.ui.toast(`Server ready on :${port}`)
941 }
942 }
943
944 const tunnelUrl = findTunnelUrl(raw, extraHosts)
945
946 if (tunnelUrl) {
947 const known = await read($, tunnel)
948
949 if (known?.url !== tunnelUrl) {
950 await update($, tunnel, () => ({ label, url: tunnelUrl }))
951 $.ui.toast(`Tunnel ready: ${tunnelUrl} — press u to copy`, { timeoutMs: 8000 })
952 }
953 }
954
955 if (hasError(stripAnsi(raw)) && at - (lastToast[label] ?? 0) > TOAST_GAP_MS) {
956 lastToast[label] = at
957 $.ui.toast(`${label} — ${stripAnsi(raw).trim().slice(0, 80)}`, { timeoutMs: 6000 })
958 }
959}
960
961async function emit($: EngineInterface, input: DevEvent | DevEvent[]) {
962 const events = Array.isArray(input) ? input : [input]
963 const time = new Date(await $.clock.now()).toISOString()
964 const stamped = events.map((event): DevEvent => ({ ...event, time }))
965
966 await update($, dev, current => stamped.reduce(applyEvent, current))
967
968 const entries = stamped.filter(addsEntry)
969
970 if (entries.length > 0) {
971 const chosen = await read($, selected)
972 const isFiltered = await read($, onlyErrors)
973 const withName = chosen === null || chosen === ALL
974 const added = entries
975 .filter(
976 event =>
977 (withName || chosen === event.label) && (event.type !== 'output' || !isFiltered || isProblem(event.text ?? '')),
978 )
979 .reduce(
980 (sum, event) =>
981 sum + (event.type === 'output' ? countRows([{ label: event.label ?? '', text: event.text ?? '', at: 1 }], withName) : 1),
982 0,
983 )
984
985 if (added > 0) {
986 await update($, scrollBack, current => (current > 0 ? current + added : 0))
987 }
988 }
989
990 for (const event of stamped) {
991 await observe($, event, Date.parse(time))
992 }
993}
994
995async function readBranch($: EngineInterface, cwd?: string) {
996 const git = (argv: string[]) => (cwd ? $.process.run(argv, { cwd }) : $.process.run(argv))
997 const named = await git(['git', 'branch', '--show-current'])
998 const detached = named.exitCode === 0 && !named.stdout.trim() ? await git(['git', 'rev-parse', '--short', 'HEAD']) : null
999
1000 return { ok: named.exitCode === 0, named: named.stdout, detached: detached?.stdout ?? '' }
1001}
1002
1003async function refreshSite($: EngineInterface) {
1004 const top = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--show-toplevel', '--show-prefix'])
1005 const [toplevel = '', prefix = ''] = top.exitCode === 0 ? top.stdout.split('\n').map(line => line.trim()) : []
1006 siteDir = prefix ? `${toplevel}/${prefix.replace(/\/$/, '')}` : toplevel
1007
1008 await loadAppUrl($)
1009}
1010
1011async function followCheckout($: EngineInterface): Promise<boolean> {
1012 await refreshSite($).catch(() => undefined)
1013
1014 const here = siteDir || (await $.session.cwd())
1015 const ready = !pinnedDir && (await $.fs.exists(`${here}/vendor/autoload.php`).catch(() => false))
1016 const dir = pickDir({ pinned: pinnedDir, here, ready, current: project.dir, main: mainDir })
1017
1018 if (dir === project.dir) {
1019 return false
1020 }
1021
1022 const moved = project.dir !== ''
1023
1024 if (moved && (await closeTunnel($, false))) {
1025 $.ui.toast('Tunnel closed — the session left its checkout', { timeoutMs: 6000 })
1026 }
1027
1028 project = { dir, name: dir.split('/').filter(Boolean).pop() ?? dir }
1029 await update($, layout, () => null)
1030
1031 return moved
1032}
1033
1034async function followSession($: EngineInterface) {
1035 if ((await followCheckout($)) && (await read($, dev)).status === 'running') {
1036 $.ui.toast(`Moved to ${project.name} — restarting the dev servers`, { timeoutMs: 6000 })
1037 await restartAll($)
1038 }
1039}
1040
1041async function watchLayout($: EngineInterface) {
1042 if (watching) {
1043 rewatch = true
1044
1045 return
1046 }
1047
1048 watching = true
1049
1050 try {
1051 do {
1052 rewatch = false
1053 await readLayout($)
1054 } while (rewatch)
1055 } finally {
1056 watching = false
1057 }
1058}
1059
1060async function readLayout($: EngineInterface) {
1061 const cwd = project.dir
1062 const branch = await readBranch($, cwd)
1063 const listed = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd })
1064
1065 if (!branch.ok || listed.exitCode !== 0) {
1066 return
1067 }
1068
1069 const next = parseLayout(branch.named, branch.detached, listed.stdout)
1070 const reason = layoutChange(await read($, layout), next)
1071 await update($, layout, () => next)
1072
1073 if (reason && (await read($, dev)).status === 'running') {
1074 $.ui.toast(`${reason} — restarting the dev servers`, { timeoutMs: 6000 })
1075 await restartAll($)
1076 }
1077}
1078
1079function ensureTick($: EngineInterface) {
1080 tick ??= $.clock.every(TICK_MS, async () => {
1081 const current = await read($, dev)
1082
1083 if (current.status !== 'stopped' || current.procs.length > 0) {
1084 await refreshSite($).catch(() => undefined)
1085 await watchLayout($).catch(() => undefined)
1086 }
1087
1088 if (anyActive()) {
1089 const at = await $.clock.now()
1090 await update($, now, () => at)
1091 }
1092 })
1093}
1094
1095async function basePort($: EngineInterface): Promise<number | null> {
1096 return (await read($, override)) ?? (await read($, configured))
1097}
1098
1099async function readPorts($: EngineInterface) {
1100 return {
1101 override: await read($, override),
1102 configured: await read($, configured),
1103 detected: await read($, detected),
1104 }
1105}
1106
1107async function loadAppUrl($: EngineInterface) {
1108 const text = await $.fs.read(`${siteDir || project.dir}/.env`).catch(() => '')
1109 await update($, appUrl, () => parseEnvValue(String(text), 'APP_URL') || null)
1110}
1111
1112async function loadConfigured($: EngineInterface) {
1113 const text = await $.fs.read(`${project.dir}/.env`).catch(() => '')
1114 await update($, configured, () => parseEnvPort(String(text)))
1115 await loadAppUrl($)
1116}
1117
1118async function processEnv($: EngineInterface): Promise<Record<string, string>> {
1119 const port = await basePort($)
1120 const colour = { COLUMNS: '120', FORCE_COLOR: '3', CLICOLOR_FORCE: '1' }
1121
1122 return port === null ? colour : { ...colour, SERVER_PORT: String(port) }
1123}
1124
1125async function addNote($: EngineInterface, text: string) {
1126 await update($, dev, current => ({ ...current, notes: [...current.notes, text].slice(-50) }))
1127}
1128
1129async function resetDetection($: EngineInterface) {
1130 await update($, detected, () => null)
1131 await update($, tunnel, () => null)
1132}
1133
1134async function settle($: EngineInterface) {
1135 const active = anyActive()
1136
1137 if (!active) {
1138 await resetDetection($)
1139 }
1140
1141 await update($, dev, (current): Dev => ({
1142 ...current,
1143 status: active ? (current.status === 'stopping' ? 'stopping' : 'running') : 'stopped',
1144 }))
1145}
1146
1147async function killGroup($: EngineInterface, pgid: number, graceMs = TERM_GRACE_MS) {
1148 await $.process.run(['kill', '-TERM', '--', `-${pgid}`])
1149
1150 for (let waited = 0; waited < graceMs; waited += 100) {
1151 const alive = await $.process.run(['kill', '-0', '--', `-${pgid}`])
1152
1153 if (alive.exitCode !== 0) {
1154 return
1155 }
1156
1157 await $.clock.sleep(100)
1158 }
1159
1160 await $.process.run(['kill', '-KILL', '--', `-${pgid}`])
1161}
1162
1163function lineEvent(runner: Runner, raw: string): DevEvent {
1164 const line = afterCarriage(raw)
1165 const pgid = runner.pgid === null && line.startsWith(PGID_MARK) ? Number(line.slice(PGID_MARK.length)) : NaN
1166
1167 if (Number.isInteger(pgid) && pgid > 1) {
1168 runner.pgid = pgid
1169
1170 return { type: 'pid', label: runner.label, pid: pgid }
1171 }
1172
1173 return { type: 'output', label: runner.label, text: line.slice(0, MAX_LINE), quiet: runner.stopping }
1174}
1175
1176async function emitLines($: EngineInterface, runner: Runner, lines: string[]) {
1177 if (lines.length > 0) {
1178 await emit($, lines.map(line => lineEvent(runner, line)))
1179 }
1180}
1181
1182async function resume($: EngineInterface, runner: Runner) {
1183 try {
1184 if (await startRunner($, runner)) {
1185 $.ui.toast(`${runner.label} is back up`)
1186 }
1187 } catch (error) {
1188 await addNote($, `${runner.label}: ${String(error)}`)
1189 }
1190}
1191
1192async function pump($: EngineInterface, runner: Runner, run: Child, startedAt: number) {
1193 const rests = { stdout: '', stderr: '' }
1194
1195 try {
1196 for await (const { stream, text } of run) {
1197 const split = splitLines(rests[stream], text)
1198 const overflow = split.rest.length > MAX_LINE
1199
1200 rests[stream] = overflow ? '' : split.resttypes/index.d.ts 76 lines1export type ProcState = 'up' | 'restarting' | 'exited'
2
3export type Proc = {
4 label: string
5 command: string
6 color: string
7 pid: number
8 state: ProcState
9 startedAt: number
10 errors: number
11 lines: string[]
12}
13
14export type Entry = { label: string; text: string; at: number }
15
16export type Dev = {
17 status: 'stopped' | 'running' | 'stopping'
18 procs: Proc[]
19 feed: Entry[]
20 notes: string[]
21}
22
23export type DevEventType = 'start' | 'pid' | 'output' | 'exit' | 'failed' | 'restarting'
24
25export type DevEvent = {
26 type: DevEventType
27 label?: string
28 command?: string
29 color?: string
30 pid?: number
31 text?: string
32 code?: number
33 signal?: string | null
34 quiet?: boolean
35 time?: string
36}
37
38
39export type Layout = { branch: string; worktrees: string[] }
40
41export type ShareState = 'starting' | 'checking' | 'open' | 'broken' | 'failed'
42
43export type LeftoverKind = 'local-host' | 'plain-http' | 'vite' | 'broken'
44
45export type Leftover = { kind: LeftoverKind; url: string }
46
47export type Share = {
48 state: ShareState
49 dir: string
50 port: number | null
51 url: string | null
52 leftovers: Leftover[]
53 note: string | null
54}
55
56declare module 'claude-code' {
57 interface PluginState {
58 'artisan-dev': {
59 dev: Dev
60 selected: string | null
61 now: number
62 isOpen: boolean
63 override: number | null
64 configured: number | null
65 appUrl: string | null
66 detected: number | null
67 back: number
68 onlyErrors: boolean
69 tunnel: { label: string; url: string } | null
70 share: Share | null
71 attached: { label: string | null; text: string } | null
72 layout: Layout | null
73 }
74 }
75}
76