SLOPSHOPPER

artisan-dev

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…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · artisan-dev
│ ┃ dev ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ● a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 │ artisan-dev │ │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ Couldn't list the dev processes: dev:list │ │ ┃ ────────── ⎿ Read 6 lines │ gave nothing │ │ ┃ [ Start all (s) ] [ Restart all (r) ] ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ [ Restart one (t) ] [ Open site (o) ] ⎿ Added 2 lines, removed 1 line │ ┃ [ Open tunnel (n) ] [ Copy log (c) ] ⏺ Bash(bun test) │ ┃ [ Ask Claude (a) ] ⎿ 3 pass, 1 fail │ ┃ │ ┃ ── Processes ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ───────────────────────────────────────── │ ┃ Not running — press s to start everything. ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ── Log · all ────────────────────────── e: E › /dev │ ⎿ artisan-dev: Couldn't start the dev servers: dev:list gave nothi │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · dev
● a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 stopped ────────────────────────────────────────────────────── [ Start all (s) ] [ Restart all (r) ] [ Restart one (t) ] [ Open site (o) ] [ Open tunnel (n) ] [ Copy log (c) ] [ Ask Claude (a) ] ── Processes ───────────────────────────────────────── Not running — press s to start everything. ── Log · all ────────────────────────── e: Errors only
README

artisan-dev

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.

Requirements

  • A Claude Code build that loads plugin modules (mods).
  • A Laravel app whose Artisan has dev and dev:list.
  • macOS: processes are launched through perl (preinstalled) and the site is opened with open.
  • For /dev tunnel open: cloudflared on the PATH (brew install cloudflared), plus lsof and curl (preinstalled on macOS).

Usage

/dev starts the processes and opens the pane. Add a subcommand to do one thing:

CommandWhat it does
/devStart everything and open the pane
/dev startStart everything, no pane
/dev stopStop everything and clear the log
/dev restart [name]Restart everything, or one process by name or number
/dev statusShow what is running
/dev siteOpen the site
/dev ask [name]Attach the server state and recent output to your next prompt
/dev clearClear the log
/dev errorsToggle showing only errors and warnings
/dev copy [name]Copy a process log (or the all view) to the clipboard
/dev tunnelShow and copy the public tunnel URL, or the open tunnel's report
/dev tunnel openOpen a public tunnel to this checkout and check its links (again: check them again)
/dev tunnel closeClose that tunnel and start Vite again
/dev helpList the commands

Port

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.

The pane

  • Processes — 0 shows the combined log in arrival order; 1–9 pick a process. The up and down arrows move between process names only.
  • Toolbar — 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).
  • Log — keeps each process's own colours, wraps long lines, and scrolls with the wheel, page keys, Home and End. e (or the button in the Log divider) shows only errors and warnings.
  • Band above the prompt — status dot, a clickable site link (see Port above), how many processes are up, an error count and the tunnel's state. + and − open and close the pane.

Errors

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.

Crashes and restarts

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.

Tunnels

/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:

  1. stops the dev process that runs Vite (its command runs 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;
  2. runs the app's build script with the lockfile's package manager;
  3. starts its own 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;
  4. once 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:

LinkWhyFix in the app
https://site.test/…, localhost, 127.0.0.1The app builds links from APP_URLLet web requests use their own host: drop URL::useOrigin or URL::forceRootUrl for them
http://<tunnel>/…The app thinks the request is plain httpURL::forceScheme('https') in AppServiceProvider
:5173A Vite dev server is writing public/hotStop Vite in this checkout
Any asset that does not answer 200The 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.

Settings

SettingDefaultPurpose
projectDiremptyAbsolute 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.
tunnelHostsemptyExtra comma-separated domains to treat as a tunnel, for providers not built in.

Development

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.

License

MIT

Source 2 files
hooks/register.tsx 2655 lines
1import 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('&amp;', '&').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.rest
types/index.d.ts 76 lines
1export 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