Unofficial Claude Code mod for Laravel apps (not affiliated with Laravel LLC): live log and queue monitoring, managed queue/Vite/scheduler, artisan, routes…

An unofficial Claude Code mod for Laravel apps.
Unofficial. This is a community project by Mark Szymik. It is not made, endorsed or supported by Laravel LLC or Anthropic. "Laravel" is a trademark of Laravel LLC and is used here only to describe what the mod works with.
Tooling for Laravel apps inside Claude Code: a /lara pane, managed dev processes (queue, Vite, scheduler), artisan/npm/composer commands, and tools Claude can call. It's a mod, so it runs inside Claude Code and draws its own interface.
Needs Claude Code v2.1.295 or later (claude --version). In a Claude Code terminal session, run:
/plugin install lara-cc --marketplace markszymik/lara-cc
Answer y to add the marketplace, then press Enter to install for your user. It's active straight away and loads in every session after that. To update later, run /plugin, open the Installed tab, and update it there.
Start Claude Code from your Laravel project's root (the folder with artisan).
Coming from the old laravel plugin? This mod used to be called laravel (marketplace markszymik-laravel), with /laravel-* commands. Uninstall that one from /plugin → Installed, then install lara-cc as above. The commands are now /lara, /lara-artisan and so on, and the pane's toggles may start from their defaults again.
A mod is code that runs with your permissions: it starts processes and reads files in your project. Read
hooks/register.tsxbefore installing if you want to see exactly what it does, or runclaude plugin validateon a clone to list every call it makes.
Live log monitoring. The mod follows the newest storage/logs/laravel*.log (single or daily channel) for the whole session. New errors pop up as a toast and a count in the status band. The Log tab shows entries coloured by level, with filters (all / warnings+ / errors). Each error shows where it happened, pointing at your own code rather than the vendor/ frame it was thrown from. ask Claude about latest error sends the error and its stack to Claude to fix.
Queue monitoring. While the mod runs your queue worker, the Queue tab shows each job as it runs, finishes or fails, with timings and session totals. It also shows pending jobs per queue and your stored failed jobs (read from the app itself, so any failed-job driver works), each with retry, forget and ask Claude. A failing job raises a toast. If QUEUE_CONNECTION=sync, it tells you a worker isn't needed.
Status band. One line above the prompt, shown only when there's something to know: running processes, queue totals, new errors, failed jobs, failing tests, pending migrations. Press open to jump to the right tab.
It also flags the things that make a change "not work" without an error, and the Services tab explains each one. Most come with a one-press fix next to them, in the band and on the Services tab. A fix runs only when you press it, never from Claude, and its output lands on the Commands tab:
✗ vite exited (1), until you open the Services tab. Fix: restart.? 3 jobs waiting, no worker in this session, when this session runs no worker and the count hasn't dropped since a minute before (a worker elsewhere would drain it, and lara-cc can't see one). Fix: start worker. The queue is checked every minute while no worker runs.public/hot with no Vite behind it: pages load without CSS and JS. Vite running in another terminal counts; only dev servers on this machine (localhost, *.test) are ever checked. Fix: start vite. When curl gets no answer either way (a timeout, a certificate or DNS error, no curl), it shows ? Vite unchecked (public/hot) instead, until Vite answers.config:clear, route:clear or event:clear for the caches that exist; not optimize:clear, which would also empty your application cache.artisan about, not from .env).Herd MySQL stopped while the database connection points at its port (also Redis, PostgreSQL, MariaDB, MongoDB, Reverb, Meilisearch, Typesense). The Services tab lists every Herd service under Herd services (port check): a port that answers means something listens there, not proof that it's Herd's service. A stopped one has a start button. It uses Herd's own AppleScript command, the same one Herd's MCP server uses; macOS asks once whether your terminal may control Herd. Only your press starts a service: Claude has no tool for it. The mod reads Herd's services.plist (type, port and version, no credentials) and asks the app's config for the driver and port of each server on this machine it uses (database, cache, queue, session, broadcasting, Scout). Only those two values come back: passwords, usernames, URLs and remote hosts stay inside PHP and never reach the mod or Claude. A server on a port no Herd service has (Docker, DBngin) is left alone, and Herd is never asked for its site list.composer install needed when vendor/ differs from composer.lock, and npm install needed when node_modules differs from package-lock.json (for pnpm, Yarn and Bun, when node_modules is missing), e.g. after a git pull. Fix: composer install / npm install.⚠ marks something the mod saw (a file exists, nothing answers on the port) and ? a conclusion or something it couldn't check, e.g. ? 3 jobs waiting, no worker in this session or ? Herd MySQL unchecked when nc can't probe the port. A count whose re-read failed (or, for failed jobs, couldn't count them) stays, dim, as 1 failed job (last read failed) or 2 pending migrations (last check failed), rather than disappearing; failed jobs that were never counted show as ? failed jobs unknown. If the checks stop finishing, the band shows ? checks stale with recheck.
When the band is too narrow, the fix buttons go first, then the warnings fold into a count (? 2 unchecked when none of them was seen for certain).
The checks run at start-up, after each of Claude's turns, after commands and every minute. They read lockfiles, Laravel's marker files and Herd's service list, never .env. The Services tab's first row says when they last ran, with recheck, and lists what they couldn't check (not checked: APP_ENV (artisan about failed), migrations (migrate:status failed)…), so nothing unchecked passes for fine. A process row reads not started or stopped, which means in this session: lara-cc can't see one run elsewhere.
Test results. Every test run in the session counts: /lara-test, the Tests tab, the Commands tab, Claude's artisan tool, and the php artisan test, Pest or PHPUnit runs Claude does through Bash. The band shows ✗ 2 tests failing until a run passes (and ✓ 32 tests next to anything else it shows). Once Claude edits PHP, during the run or after, the result no longer covers the code: a passing run leaves the band, a failing one turns yellow (✗ 2 tests failed, edited since), and the Tests tab says it wasn't re-run. The Tests tab shows the last run, each failure with its message and where it failed, run tests, and ask Claude for one failure or all of them, redacted like everything sent.
Since Claude's edits. lara-cc notes which files Claude edits with its Edit, Write and NotebookEdit tools (paths inside the project and what kind each is, never contents), and lists the checks that apply to them: Syntax, Tests, Pint, Migrations, Routes, Views and the queue worker. The list is at the top of the Tests tab, one line on the Services tab, and Claude's health tool reads the same rows. Each reads passed, failed, not run, skipped (a setting says not to), unknown (lara-cc can't tell) or done (the worker restarted). A check passes only if it ran after the last edit and succeeded: a test run from before the edit reads not run, a subset (--filter, --dirty and the like) says so, a run that dies before printing a summary is failed (for Claude's own Bash runs, only when the runner actually started: the command is the runner, or PHPUnit's or Pest's banner is in the output, so ls vendor/bin/pest failing is not a crashed run) and one that exits non-zero after a summary is unknown, and an edited migration is never passed since lara-cc runs none. No error in the log is not a pass: the log gets its own sentence (no errors have arrived since 14:03:10 so far, and, once ten minutes after the turn have passed, no errors arrived 14:03:10-14:13:40), only when the log was followed the whole time, and is unknown otherwise. Once a turn ends the band shows ✗ syntax error or ✎ 2 files: tests not run, never green, and a toast says the same.
php -l on each PHP file Claude edited, when the turn ends, is the only new automatic step here (the queue restart and Pint, below, already ran on their own, each with its own setting): it only parses (no autoloader, no app boot, no database), for at most 20 files, never an env file (.env.php), and never a file whose real path is outside the project (a symlink): that one is counted as not linted, and the row says so. A file that failed to parse, or that the check didn't reach or that was edited while it ran, is carried into the next stretch and checked again at its end, so a fix made in your editor clears it; until then the row says it is still failing from before. syntax check after each turn on the Services tab turns it off, and the Syntax row then says skipped (naming a parse error left over from before that nothing re-checks while it is off: failed before: app/Models/Order.php line 41). Everything else is suggested, never run for you: the report names the artisan or service step with whether it asks first, like Claude's tools do, and each row's own button (migrate, restart queue and so on) is drawn under it on the Tests tab for you to press; the Tests row has none of its own, run tests at the top of the tab is it. When the queue worker should read edited config or .env values but config is cached, a restart isn't enough: the row says not run, and to clear the config cache first. A parse error's text is shown only to you, on the Tests tab; Claude gets the file and line. Claude can't fix anything through this, and gets nothing pushed into the conversation: it reads the report when it calls health.
It doesn't see changes made through Claude's Bash tool, git, artisan make:* or your editor (the report says so), nor anything under vendor/, node_modules/, storage/ or bootstrap/cache/ at the project root. A .env edit is classified by name only: the report says the worker and cached config keep the old values. php -l runs on the PHP lara-cc uses (Herd's for the site), which can differ from the app's, so a failure says did not parse on this PHP.
Routes. The Routes tab lists every route in aligned columns: the method in colour, the URI with its {parameters} picked out, the name and the controller. Type in filter (or press f) to narrow it; Enter there moves to the list. The ↑ and ↓ keys walk the rows (marked ›) and the list scrolls with them; the line under it shows where the selected route's code is. Enter on a route asks whether to open its code (the controller method, closure or view, at its line) or its page in the browser; when only one applies (a POST route has no page), it opens that. A page whose URL has parameters asks for each value it needs first (1, or type one): a parameter with a route default or a URL::defaults value takes that, and optional ones are left out. Laravel's own URL generator builds the URL, so the port and the route's domain are as the app has them. /lara-routes and Claude's routes tool read the same list. The file opens in the editor named by Laravel's own editor setting in config/app.php (the one its error page uses, e.g. phpstorm, vscode, cursor, zed), otherwise the editor whose terminal Claude Code runs in, otherwise (or when the editor's link doesn't open) whatever opens .php files.
Herd debug session. With Herd Pro, start Herd debug session on the Services tab makes Herd record the queries, logs, dumps and HTTP calls the app makes; stop hands them to the mod, which shows a count of each. They stay in the mod: ask Claude asks you first, withholds query bindings, headers, cookies and request bodies, and redacts the rest. (Herd's Xdebug isn't something a mod can switch: Herd turns it on by itself when a request asks for a debug session.)
Laravel Boost. When the app has Laravel Boost, its read-log-entries, last-error and browser-logs tools ask you and are redacted exactly like lara-cc's logs tool. Its get-config refuses keys that can hold a secret (app.key, *.password, *.secret, tokens, URLs), and the output of every other Boost tool (tinker, database-query, schema, app info) is redacted. Boost's two log tools step behind lara-cc's logs tool (they load on request) and say to prefer it, so Claude doesn't have two tools for the same thing.
Dev processes. Queue worker (or Horizon), Vite and the scheduler, started and stopped from the Services tab or commands, with their output. Tick autostart to start them whenever you open Claude Code in this project.
After Claude edits PHP. The queue worker restarts so it runs the new code (on by default; a restart that doesn't keep the worker up for a second is said, not assumed). At the end of each turn, php -l checks the PHP files Claude changed for syntax errors (on by default; see Since Claude's edits). Optionally, Pint formats them too (off by default).
Herd's PHP version. When the php on your PATH is Laravel Herd's, artisan, Composer, Pint and the managed processes run on the PHP binary Herd serves your site with, so an isolated site (herd isolate 8.3) gets its own version even when php in your shell is another one. The Services tab shows which version, and says when your shell's php differs. If your PATH php comes from somewhere else (Homebrew, asdf), the mod keeps using it. The mod asks Herd only about the current folder: it reads no site list and no .env.
At start-up it checks migrate:status and tells you about pending migrations.
| Command | What it does | |||||||
|---|---|---|---|---|---|---|---|---|
/lara [tab] | Opens the pane: Services, Queue, Log, Tests, Routes, Commands, About (hotkeys s q l t r c a) | |||||||
| `/lara-logs [errors\ | warnings\ | all] [count]` | Recent log entries in the transcript, so Claude sees them too (default: last 5 errors) | |||||
| `/lara-queue [start\ | stop\ | restart\ | logs\ | status\ | failed\ | retry <id\ | all>]` | Queue worker (queue:work, or horizon if installed) and failed jobs |
| `/lara-vite [start\ | stop\ | restart\ | logs]` | npm run dev (pnpm/yarn/bun detected from the lockfile) | ||||
| `/lara-schedule [start\ | stop\ | restart\ | logs]` | schedule:work | ||||
/lara-artisan <args> | php artisan <args> --no-interaction; output goes to the transcript so Claude sees it | |||||||
/lara-test [args] | php artisan test, e.g. /lara-test --filter=OrderTest | |||||||
/lara-routes [filter] | Route list, filtered | |||||||
/lara-npm <script> | npm run <script> (defaults to build) | |||||||
/lara-composer <args> | Composer |
A command you type runs as typed. One that reaches the mod some other way (Claude, a plugin, a relayed message) gets the same checks as Claude's tools: see What Claude can do on its own.
Type /lar to see them all. Processes run for the life of the session and stop when it ends. /lara, /lara-logs, /lara-queue, /lara-vite and /lara-schedule work while Claude is mid-turn.
logs tool: recent log entries with where they happened and the stack, so Claude can check for errors after running code or tests. Asks you first (see Privacy).queue tool: pending jobs, jobs run this session, failed jobs with their exceptions (asks you first), and retrying them (asks you first).artisan tool: runs artisan commands. Refuses anything that can destroy data or run arbitrary code: migrate:fresh/reset/refresh/rollback, db:wipe, db:seed, key:generate, down, queue:flush/clear/forget, the prune/clear commands of the queue, models, Horizon, Telescope and Pulse, tinker, and the --force, --env, --prune and --seed options. It also refuses env:encrypt and env:decrypt, which write files and print the encryption key; /lara-artisan refuses those too, since its output goes into the conversation, so run them in your own terminal. config:show and db:show print passwords, keys, hosts and users, so their output is shown only to you, on the pane's Commands tab, and Claude is told it was shown, not what it said; when Claude asks for one, you're asked first, and when you type it with /lara-artisan it just shows. Two everyday commands keep working for Claude with the confidential part taken out: about with only versions, drivers, the environment and other plain values (it's read as JSON, and the app's URL, hosts, IPs, paths and anything else not on that list show as [not shown]), and route:list without route domains (it runs as --json so the domain can be dropped, with whatever options were asked for; a route with its own domain says so). Artisan resolves the command name first, so abbreviations like migr:fr are caught too. Run those yourself with /lara-artisan, which skips the check only when you type it (not when it's relayed from another session, a channel or a plugin). Commands that reach past the project folder ask you first (see below).service tool: start, stop, restart or read the output of the queue worker, Vite and the scheduler. Starting the queue worker, Horizon or the scheduler asks you first.routes tool: filtered route list.health tool: what lara-cc knows about the app's health and how it knows it: each problem, what it couldn't tell and what it found fine, marked seen, inferred or unknown with its age, and for each problem the next step and whether it runs on its own, asks you, or is yours to do. It re-runs the checks, the queue read and migrate:status, changes nothing, and sends only counts, drivers, local ports and file names, never log or exception text. After Claude edits files it also lists which checks ran on those edits and which passed, failed, were skipped or not run (see Since Claude's edits above); a parse error reaches Claude as a file and a line.| Examples | Asks you? | |
|---|---|---|
| Reads | routes, queue counts, process status and output, health (what the checks found, marked seen, inferred or unknown, and what ran on Claude's edits) | No (output redacted) |
| Reads that can hold confidential data | log entries, failed-job exceptions, Boost's log tools, a Herd debug session | Yes, see Privacy |
| Changes inside the project folder | Laravel's make:* generators (and Livewire's, Volt's and Filament's file generators), route:cache, config:clear, view:clear; Vite; stopping a process, or restarting one that's running (so Claude can reload the worker after changing job code) | No |
| Reaches past the folder: the database, the queue, the cache | migrate, queue:retry (and the queue tool's retry), queue:work, queue:restart, schedule:run/test/work, cache:clear, optimize:clear, Horizon's terminate/pause/continue, test unless phpunit.xml sends the tests to an in-memory database (RefreshDatabase would reset a real one), and any artisan command lara-cc doesn't know to stay in the folder (a package's own, such as make:filament-user); starting the queue worker, Horizon or the scheduler; /lara-composer and /lara-npm when you didn't type them | Yes |
| Prints secrets | config:show, db:show | Yes, and the output is shown only to you, in full, on the pane's Commands tab |
| Can destroy data or run any code | migrate:fresh and the other rollbacks, db:wipe, db:seed, tinker, --force, --env… (listed above) | Refused |
The question names the command, what it does, APP_ENV, and which of the app's servers lara-cc found on this machine (driver and port only; if it hasn't read them yet or couldn't, it says so, and if artisan about has failed since, how old APP_ENV is), because a database or cache can be shared or not on this machine at all, and APP_ENV=local doesn't prove otherwise. Don't run sends Claude a refusal it's told not to retry. With nobody to answer (claude -p), the answer is no. What you press in the pane or the band, and commands you type, never ask.
These checks cover lara-cc's own tools and commands only. Claude Code's Bash and Read tools are separate: Claude could still run php artisan migrate:fresh or read .env through them if your permissions allow it. To block that, add deny rules to your Claude Code settings (.claude/settings.json in the project, or ~/.claude/settings.json):
{
"permissions": {
"deny": [
"Bash(php artisan migrate:fresh:*)",
"Bash(php artisan migrate:refresh:*)",
"Bash(php artisan migrate:reset:*)",
"Bash(php artisan migrate:rollback:*)",
"Bash(php artisan db:wipe:*)",
"Bash(php artisan db:seed:*)",
"Bash(php artisan tinker:*)",
"Bash(php artisan config:show:*)",
"Bash(php artisan queue:retry:*)",
"Read(./.env)",
"Read(./.env.*)"
]
}
}
Deny rules match a command's beginning, so they're a speed bump rather than a wall (php -r, a script or another spelling gets past them). Keep Claude Code asking before Bash commands you haven't allowed if that matters to you.
Laravel logs and failed-job exceptions can contain confidential data: email addresses, SQL values, tokens, request data. The mod reads them to show you the Log and Queue tabs, toasts and the band, and none of that goes to Claude.
They reach Claude only through the logs and queue tools, /lara-logs, /lara-queue failed, Laravel Boost's log tools, a Herd debug session's ask Claude, and the other ask Claude buttons. Each time, the mod first asks you in a dialog that explains this: Send redacted, Allow this session, or Don't send. If you dismiss the dialog, or nobody is there to answer (claude -p), nothing is sent.
Before anything is sent, and in every command output that goes into the conversation (artisan, tests, npm, Composer, process output, error messages), the mod hides what looks confidential:
.env lines, headers, query strings, PHP arrays, JSON (also JSON escaped inside a string) and PHP-serialized queue payloadslocalhost, 127.0.0.1, ::1): under a host key (Host:, DB_HOST=, "host":), after a URL's user, in server URLs like tcp://10.0.0.5:6379, and in database connection errors@ included), bearer and basic auth, the user in digest auth, base64: keyshooks/register.tsx 3827 lines1import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register } from 'claude-code'
2
3import {
4 artisanCommandName,
5 clock,
6 composerDrift,
7 connectionsPhp,
8 durationMs,
9 editKind,
10 editorHref,
11 errorKind,
12 exceptionHeadline,
13 feedLog,
14 formatEntry,
15 headline,
16 helpName,
17 herdNeeds,
18 herdServices,
19 hotPort,
20 isError,
21 isIgnoredRel,
22 isTestCommand,
23 testRunnerStarted,
24 isWarning,
25 lintResult,
26 localHotUrl,
27 logStamp,
28 methodLabel,
29 newLogState,
30 npmDrift,
31 openPagePhp,
32 origin,
33 pageNotOpened,
34 parseConnections,
35 parseDebugCapture,
36 parseRoutes,
37 parseSnapshot,
38 parseTestRun,
39 parseWorkerLine,
40 PHP_OPTIONS,
41 phpJson,
42 queuesOf,
43 aboutForClaude,
44 clip,
45 redact,
46 relativeTo,
47 parseRouteList,
48 testsUseMemoryDb,
49 redactData,
50 refusedOptions,
51 routesPhp,
52 routeWindow,
53 shortClass,
54 snapshotPhp,
55 splitArgs,
56 stripAnsi,
57 tail,
58 terminalEditor,
59 testScope,
60 verifyBand,
61} from './parse'
62import type { AppConnection, EditKind, FailedJob, HerdService, LogEntry, QueueSnapshot, TabRoute, TestFailure, TestRun } from './parse'
63
64/**
65 * lara-cc: an unofficial Claude Code mod for Laravel apps. Not affiliated with or endorsed by Laravel LLC or Anthropic.
66 *
67 * /lara [tab] open the pane (services, queue, log, routes, commands, about)
68 * /lara-artisan <args> run `php artisan <args>`; output lands in the transcript for Claude
69 * /lara-queue [start|stop|restart|logs|status|failed|retry <id|all>]
70 * /lara-vite|-schedule [start|stop|restart|logs]
71 * /lara-logs [errors|warnings|all] [count]
72 * /lara-test [args] php artisan test
73 * /lara-routes [filter] route list
74 * /lara-npm <script> `npm run <script>` (pnpm / yarn / bun detected from the lockfile)
75 * /lara-composer <args> run composer
76 *
77 * Claude gets tools: artisan, service, routes, logs, queue and health.
78 */
79
80type Api = EngineInterface
81
82// ─── Config: edit these to taste ───────────────────────────────────────────
83
84/** Long-running processes the mod can start, stop and restart. `vite` and `queue` are adjusted at start-up. */
85const SERVICES = {
86 queue: { label: 'Queue worker', argv: ['php', 'artisan', 'queue:work', '--tries=1'] },
87 vite: { label: 'Vite dev', argv: ['npm', 'run', 'dev'] },
88 schedule: { label: 'Scheduler', argv: ['php', 'artisan', 'schedule:work'] },
89} satisfies Record<string, { label: string; argv: string[] }>
90
91/** One-press commands on the Commands tab (digit = hotkey). `npm` is swapped for the detected package manager. */
92const QUICK: { key: string; label: string; argv: string[] }[] = [
93 { key: '1', label: 'npm run build', argv: ['npm', 'run', 'build'] },
94 { key: '2', label: 'migrate', argv: ['php', 'artisan', 'migrate', '--no-interaction'] },
95 { key: '3', label: 'optimize:clear', argv: ['php', 'artisan', 'optimize:clear'] },
96 { key: '4', label: 'test', argv: ['php', 'artisan', 'test'] },
97 { key: '5', label: 'pint', argv: ['vendor/bin/pint'] },
98 { key: '6', label: 'queue:restart', argv: ['php', 'artisan', 'queue:restart'] },
99 { key: '7', label: 'composer install', argv: ['composer', 'install', '--no-interaction'] },
100]
101
102/**
103 * Artisan commands Claude's tool refuses, however they're spelled: artisan itself resolves the name first,
104 * so `migr:fr` or `MIGRATE:FRESH` count as migrate:fresh. `tinker` is here because it runs any PHP.
105 * You can still run them yourself with /lara-artisan.
106 */
107const DESTRUCTIVE = [
108 'migrate:fresh', 'migrate:reset', 'migrate:refresh', 'migrate:rollback', 'db:wipe', 'db:seed', 'key:generate', 'down',
109 'queue:flush', 'queue:clear', 'queue:forget', 'queue:prune-failed', 'queue:prune-batches', 'model:prune',
110 'horizon:clear', 'horizon:forget', 'telescope:clear', 'telescope:prune', 'pulse:clear', 'tinker',
111]
112
113/**
114 * Options the tool refuses on any command: `--force` skips the production prompt and overwrites files (`-f` on make:*),
115 * `--env` points artisan at another environment's database, `--prune` deletes migration files, `--seed` runs seeders.
116 */
117const DESTRUCTIVE_OPTIONS = ['--force', '-f', '--env', '--prune', '--seed', '--seeder']
118
119/**
120 * Artisan commands Claude's tool runs only once the person says yes, with what each does: they reach past this
121 * folder's files into the database, the queue or the cache, which may be shared or not on this machine at all.
122 * APP_ENV saying `local` doesn't make that safe, so the question shows what the mod knows of where they land.
123 */
124const IMPACTFUL: Record<string, string> = {
125 migrate: 'changes the database schema',
126 'migrate:install': 'creates the migrations table in the database',
127 'queue:retry': 'runs failed jobs again, with whatever they do (emails, payments, API calls)',
128 'queue:retry-batch': 'runs a batch of failed jobs again, with whatever they do (emails, payments, API calls)',
129 'queue:work': 'runs queued jobs, with whatever they do (emails, payments, API calls)',
130 'queue:listen': 'runs queued jobs, with whatever they do (emails, payments, API calls)',
131 horizon: 'runs queued jobs, with whatever they do (emails, payments, API calls)',
132 'queue:restart': 'tells every worker that shares this cache to restart, including any elsewhere',
133 'horizon:terminate': 'stops Horizon wherever it runs on this cache',
134 'horizon:pause': 'pauses Horizon wherever it runs on this cache',
135 'horizon:continue': 'resumes Horizon wherever it runs on this cache',
136 'schedule:run': 'runs the scheduled tasks that are due, with whatever they do',
137 'schedule:test': 'runs a scheduled task, with whatever it does',
138 'schedule:work': 'runs the scheduled tasks every minute, with whatever they do',
139 'cache:clear': 'empties the application cache, which other apps or machines may share',
140 'cache:forget': 'removes an item from the application cache, which other apps or machines may share',
141 'optimize:clear': 'clears every cache, the application cache (which may be shared) included',
142}
143
144/**
145 * Artisan commands that only read, or write within the project folder (generated code, Laravel's own cache
146 * files): Claude's tool runs these without asking. Anything else asks, with IMPACTFUL's reason when it has
147 * one, since a command lara-cc doesn't know (a package's, a new one) may reach the database, queue, cache or network.
148 */
149const CONTAINED =
150 /^(make:(cache-table|cast|channel|class|command|component|config|controller|enum|event|exception|factory|interface|job|job-middleware|listener|livewire|mail|middleware|migration|model|notification|notifications-table|observer|policy|provider|queue-batches-table|queue-failed-table|queue-table|request|resource|rule|scope|seeder|session-table|test|trait|view|volt)|filament:make-\S+|make:filament-(resource|page|widget|panel|relation-manager|cluster|exporter|importer|theme)|about|list|help|env|inspire|optimize|clear-compiled|package:discover|storage:link|(route|event):(list|cache|clear)|(config|view):(cache|clear)|(config|lang|stub|vendor):publish|model:show|db:(table|monitor)|schema:dump|migrate:status|schedule:list|channel:list|queue:(failed|monitor)|ide-helper:(generate|meta|models|eloquent))$/
151
152/** Artisan commands Claude's tool refuses because they print secrets: config values (passwords, APP_KEY) or the .env encryption key. */
153const CONFIDENTIAL = ['env:encrypt', 'env:decrypt']
154
155/**
156 * Artisan commands whose output is confidential (config values, the database's host and user) but only shows
157 * information: they run for the person, with the output in the pane's Commands tab and never in the conversation.
158 * Claude asking for one asks the person first (see showToPerson).
159 */
160const SHOWN_TO_PERSON = ['config:show', 'db:show']
161
162/** Laravel Boost's MCP tools that read logs: asked for and redacted like lara-cc's logs tool. */
163const BOOST_LOG_TOOLS = /^mcp__laravel-boost__(read-log-entries|last-error|browser-logs)$/
164
165/** Every other Boost tool, in any version (tinker, database-query, schema, app info…): its output is redacted. get-config has its own guard. */
166const BOOST_OTHER_TOOLS = /^mcp__laravel-boost__(?!(read-log-entries|last-error|browser-logs|get-config)$)/
167
168/** Config keys whose value can be a secret: refused for Boost's get-config (`app.key`, `*.password`, `services.*.secret`…). */
169const SECRET_CONFIG = /(^|[._-])(key|keys|pass(word)?|pwd|secret|token|auth|credentials?|dsn|url|cookie|signature|salt|private)([._-]|$)/i
170
171const PANE = 'lara'
172const MAX_SERVICE_LINES = 400
173const TOAST_GAP_MS = 5000
174/** Minute ticks without a finished health check before its results count as stale: they run every minute, so three means they're stuck. */
175const CHECKS_STALE = 3
176
177// ─── State (module variables reset when the mod reloads; child processes die with it) ──
178
179type Tab = 'services' | 'queue' | 'log' | 'tests' | 'routes' | 'commands' | 'about'
180type Run = { stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>; done: Promise<void> }
181/** `crashed`: the exit code of a run that ended on its own with an error; `seen` once the Services tab showed it. */
182// `startedSeq`: editCount when it started, so the report knows which of Claude's edits it has loaded
183type Service = { name: string; label: string; argv: string[]; run?: Run; lines: string[]; startedAt?: number; startedSeq?: number; partial: string; crashed?: { code: number; seen: boolean } }
184/**
185 * `output`: for the Commands tab, its end (or all of it, `whole`, for output shown to the person on Claude's
186 * request). `raw`: up to 200 KB of it, redacted and cut by formatRun only when it goes to Claude.
187 */
188type LastRun = { label: string; isRunning: boolean; exitCode?: number; output: string; raw?: string; whole?: boolean }
189type Job = { job: string; time: string; status: 'running' | 'done' | 'failed'; ms?: number }
190type Settings = {
191 autoRestartQueue: boolean
192 pintAfterTurn: boolean
193 lintAfterTurn: boolean
194 showBand: boolean
195 errorToasts: boolean
196 autostart: Record<string, string[]>
197}
198
199const DEFAULT_SETTINGS: Settings = { autoRestartQueue: true, pintAfterTurn: false, lintAfterTurn: true, showBand: true, errorToasts: true, autostart: {} }
200
201// The SERVICES keys always exist; any other name (from Claude or a setting) may not, so it reads as possibly undefined
202const services = {} as Record<keyof typeof SERVICES, Service> & Record<string, Service>
203for (const [name, def] of Object.entries(SERVICES)) {
204 services[name] = { name, label: def.label, argv: [...def.argv], lines: [], partial: '' }
205}
206
207let settings: Settings = { ...DEFAULT_SETTINGS }
208let projectDir = ''
209// Counts project detections, so async work started for a folder the session has since left is dropped
210let projectRun = 0
211// Cancels the timers session.start set, so running it again doesn't add a second set
212let timers: { cancel: () => void }[] = []
213let isLaravel = false
214let hasPint = false
215let pm = 'npm'
216
217// PHP: `php` from PATH, or the binary Herd serves this folder with (see detectPhp)
218let phpBin = 'php'
219let composerArgv = ['composer']
220let phpInfo = ''
221let phpReady: Promise<void> = Promise.resolve()
222
223let tab: Tab = 'services'
224let paneOpen = false
225let logOf = 'queue'
226let lastToastAt = 0
227let restartTimer: { cancel: () => void } | undefined
228// The automatic queue restart is running (the worker is stopped, then started): not pending any more, not done yet
229let restarting = false
230let lastRun: LastRun | undefined
231let about: Record<string, Record<string, unknown>> | undefined
232let routeFilter = ''
233// The routes (with where each route's code is), the load while one runs, and why the last one failed
234let routeList: TabRoute[] | undefined
235let routesLoading: Promise<void> | undefined
236// Counts route loads, so a load for a folder the session has since left is dropped (detectProject counts one)
237let routeLoads = 0
238let routesError = ''
239// The editor Laravel's `app.editor` setting names, if the app sets one
240let appEditor = ''
241// `open` on macOS, `xdg-open` elsewhere, asked once (see openCommand)
242let opener: Promise<string> | undefined
243// The selected route on the Routes tab (an index into the filtered list), and the first row drawn
244let routeAt = 0
245let routeTop = 0
246let pendingMigrations = 0
247// Fixes running from a band or Services tab button, by label, so the button reads "running…"
248const fixing = new Set<string>()
249// A test run started by runCommand is in progress (the Tests tab's button waits for it)
250let testsRunning = false
251// The last test run this session: /lara-test, the Tests tab, the artisan tool or Claude's Bash (see noteTestRun)
252// `editedSince`: Claude edited PHP during or after it, so it no longer covers the code. `ledger`: editCount when it
253// started, so any tracked edit since (not only PHP) shows; `scope`: whether it named part of the suite; `exitCode`: when known;
254// `crashed`: the exit code of a run that ended without a summary (counts are 0, which says nothing)
255let lastTest: (TestRun & { label: string; at: number; ledger: number; scope: 'suite' | 'subset'; exitCode?: number; crashed?: number; editedSince?: true }) | undefined
256// Herd's debug session (queries, logs, dumps, HTTP calls the app made), started from the Services tab
257type DebugState = 'off' | 'starting' | 'on' | 'stopping'
258let debugState: DebugState = 'off'
259let debugCapture: { data: unknown; summary: string; at: number } | undefined
260// Laravel Boost's MCP server is installed: its log tools get the same ask and redaction as lara-cc's
261let hasBoost = false
262// The person let log entries and failed-job exceptions go to Claude for the rest of this session (see mayShare)
263let shareAllowed = false
264const editedPhp = new Set<string>()
265// Counts Claude's PHP edits, so a test run knows whether one landed while it ran (see noteTestRun)
266let phpEdits = 0
267
268// What Claude edited this stretch, for the "Since Claude's edits" report (paths and categories only, never contents).
269// Its times come from $.clock.now(), and what must be told apart (edit, check, restart) from editCount, never a mix of clocks.
270// editedPhp is Pint's list and is cleared each turn, phpEdits drives lastTest.editedSince: neither changes, this is separate.
271/** `seq`: editCount at the edit, so a check that started before it (a lint, a test run) is told apart from one after it. */
272type Edited = { kind: EditKind; seq: number }
273/** One file's `php -l`, at `at`: `raw` (the person's pane only, never Claude) is what it printed, cut; `gone`: the file no longer exists; `outside`: it is a link that leads outside the project, so it wasn't read. */
274type LintFile = { seq: number; at: number; verdict: 'passed' | 'failed' | 'unknown'; line?: number; raw?: string; gone?: true; outside?: true }
275/** A file of an earlier stretch with no clean check since its last edit: a known parse error (`failed`, with its line and when it was found), or just never checked. */
276type Carried = { failed: boolean; line?: number; at?: number }
277type Since = {
278 firstAt: number // the first edit of this stretch: opens the log window
279 lastAt: number
280 files: Map<string, Edited> // project-relative path -> its latest edit; capped at 200 (oldest dropped, never a failed one)
281 dropped: number
282 closedAt?: number // the last turn.complete chain finished; the next edit starts a new stretch
283 chains: number // turn.complete chains queued or running for this stretch
284 linting: boolean
285 lint?: { at: number; seq: number; files: Map<string, LintFile> } // seq: editCount when the last check started
286 carried: Map<string, Carried> // files from an earlier stretch that haven't linted clean since; dropped only when one does (or the file is gone)
287 pint?: { at: number; seq: number; files: number; ok: boolean }
288 restart?: { at: number; ok: boolean } // the automatic queue restart after PHP edits: when it finished, and whether the worker came back
289 errors: number // log errors that ARRIVED (by $.clock.now) from firstAt to closedAt + ERROR_WINDOW_MS
290 newestError?: LogEntry // the pane may show its headline; Claude gets only its own log stamp
291 toasted?: string
292}
293let since: Since | undefined
294// Counts every tracked edit
295let editCount = 0
296// The turn-end chains (syntax check, Pint, closing the stretch) run one after another through lintDone
297let lintDone: Promise<void> = Promise.resolve()
298// Like checksDone: a report waits for the syntax check queued behind a turn, not for Pint after it
299let syntaxDone: Promise<void> = Promise.resolve()
300const ERROR_WINDOW_MS = 10 * 60_000
301// How long a restarted queue worker must stay up before the restart counts as done
302const RESTART_SETTLE_MS = 1000
303const LINT_MAX_FILES = 20
304const LINT_BUDGET_MS = 20_000
305const SINCE_MAX_FILES = 200
306// artisan make:* commands that ran through lara-cc: files they created aren't tracked
307let artisanMade = 0
308// route:list and view:cache runs by lara-cc, with editCount when they started: the Routes and Views rows read them
309let artisanRan: Record<string, { seq: number; ok: boolean; at: number }> = {}
310type Verdict = 'passed' | 'failed' | 'not run' | 'skipped' | 'unknown' | 'done'
311/**
312 * One line of the report. `claude`: what Claude's tools can do (artisanStep, serviceStep), resolved only for the health
313 * tool; `say`: a fixed next step; `partial`: passed, but a subset of the tests. `button`: the person's.
314 */
315type Row = {
316 check: string
317 known: 'seen' | 'inferred' | 'unknown'
318 verdict: Verdict
319 detail: string
320 partial?: true
321 claude?: { artisan: string } | { service: [string, 'restart'] }
322 say?: string
323 button?: Fix
324}
325
326// Log monitoring
327let logPath = ''
328let logState = newLogState()
329let logRun: { stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult> } | undefined
330let logFilter: 'all' | 'warnings' | 'errors' = 'all'
331// When the tail first ran in this folder (0: never), and each time it stopped (until `to`, when it started again): a
332// stretch's log window counts only when no gap touches it
333let logSince = 0
334let logGaps: { from: number; to?: number }[] = []
335// When the minute tick last looked at which file is newest: a file that began since is read as history from its start
336let logLookedAt = 0
337let unseenErrors = 0
338let logReady: Promise<void> = Promise.resolve()
339
340// Health checks: things that quietly break the app (see checkHealth)
341/** `fix`: what the check's button runs, only ever on the person's press; it may return a line to show. */
342type Fix = { label: string; run: () => Promise<string | void> }
343/**
344 * A health check's finding: something wrong, or something lara-cc couldn't tell. `known`: 'seen' when the evidence
345 * shows it (a file exists, nothing listens on the port), 'inferred' for a conclusion (no worker here and the count
346 * isn't falling), 'unknown' when a probe couldn't answer. `detail`: the Services tab's line, for the person.
347 * `evidence`: Claude's health tool's line, only drivers, local ports, file names, package names, counts and
348 * errorKind(): never a host, user, URL or message text. `claude`: what Claude's own tools can do about it and
349 * whether that asks (artisanStep, serviceStep). `fromAbout`: read from `artisan about` alone, so stale when it fails.
350 */
351type Check = {
352 band: string
353 color: 'red' | 'yellow'
354 known: 'seen' | 'inferred' | 'unknown'
355 detail: string
356 evidence: string
357 fix?: Fix
358 claude?: string
359 backlog?: true
360 fromAbout?: true
361}
362/** Something a check looked at and found fine, for Claude's health tool: 'inferred' when the evidence is indirect (a port answers). */
363type Fine = { known: 'seen' | 'inferred'; evidence: string }
364/** The band's and Services tab's marker: ⚠ for something seen, ? for a conclusion or something lara-cc couldn't tell. */
365const mark = (c: Check) => (c.known === 'seen' ? '⚠' : '?')
366let checks: Check[] = []
367// What the last finished checks looked at and found fine (Claude's health tool), set with `checks`
368let checkedFine: Fine[] = []
369let checking = false
370let checkAgain = false
371// Resolves once the run in progress, and the one queued behind it, have finished (see checkHealth)
372let checksDone: Promise<void> = Promise.resolve()
373// When the checks last finished here (0: not yet), the minute ticks since (see CHECKS_STALE), and the kind of the last failure ('')
374let checkedAt = 0
375let checksMissed = 0
376let checksFailed = ''
377let depsSeen = ''
378let depsChecks: Check[] = []
379let depsFine: Fine[] = []
380// The tools whose drift files couldn't be read at the last check (tried again next time)
381let depsUnread: string[] = []
382// Herd's installed services and whether each listens; services.plist is re-read only when it changes
383type Probe = 'up' | 'down' | 'unknown'
384let herdList: (HerdService & { up: Probe })[] = []
385let herdSeen = -2
386let herdInstalled: HerdService[] = []
387// plutil couldn't read services.plist the last time it changed
388let herdUnread = false
389// Ports of the Herd services a start button is starting, so it reads "starting…" meanwhile
390const herdStarting = new Set<number>()
391// The driver and port of each local server the app's config connects to (see connectionsPhp), read with `about`;
392// undefined until connectionsPhp has answered
393let connections: AppConnection[] | undefined
394// When `artisan about` last worked, and the kind of its last failure ('' when the last one worked); see errorKind
395let aboutAt = 0
396let aboutFailed = ''
397// When migrate:status last worked (0: never here), the kind of its last failure (''), and a run count so only the newest writes
398let migrationsAt = 0
399// editCount when the last good migrate:status started, so the report knows which migration edits it saw
400let migrationsSeq = 0
401let migrationsFailed = ''
402let migrationRuns = 0
403
404// Queue monitoring
405let jobs: Job[] = []
406const stats = { done: 0, failed: 0, totalMs: 0 }
407let snapshot: QueueSnapshot | undefined
408// When the queue was last read (whether or not it worked), and when a read last worked in full
409let snapshotAt = 0
410let snapshotOkAt = 0
411// Why the last read failed: the message for the person, its kind for Claude (see errorKind)
412let snapshotError = ''
413let snapshotKind = ''
414// The last read counted the jobs waiting: in full, or all but the failed jobs (a partial read)
415let sizesRead = false
416// The read in progress, so a caller can wait for it (see recheck)
417let snapshotLoad: Promise<void> | undefined
418// The jobs waiting at the read before, to tell a backlog from a queue a worker elsewhere drains. That's the first
419// read of the minute tick before (`waitingAtTick` is this tick's), so reads close together don't each reset it
420let waitingBefore: number | undefined
421let waitingAtTick: number | undefined
422
423// ─── Small helpers ─────────────────────────────────────────────────────────
424
425function ago(ms: number): string {
426 const s = Math.max(0, Math.floor((Date.now() - ms) / 1000))
427 if (s < 60) return s + 's'
428 if (s < 3600) return Math.floor(s / 60) + 'm'
429 return Math.floor(s / 3600) + 'h' + Math.floor((s % 3600) / 60) + 'm'
430}
431
432function plural(n: number, word: string): string {
433 return `${n} ${word}${n === 1 ? '' : 's'}`
434}
435
436/** A test run's counts for Claude and the pane: `2 failed, 10 passed, 1 skipped`, or `12 passed`. */
437function testCounts(t: { passed: number; failed: number; skipped: number; crashed?: number }): string {
438 if (t.crashed !== undefined) return `exited ${t.crashed} before printing a summary`
439 return (t.failed ? `${t.failed} failed, ${t.passed} passed` : `${t.passed} passed`) + (t.skipped ? `, ${t.skipped} skipped` : '')
440}
441
442/** What the logs tool does about sending entries to Claude, for a `next:` line. */
443function logsConsent(): string {
444 return shareAllowed ? 'the user allowed log entries this session' : 'asks the user before sending entries'
445}
446
447/** `$.clock.now()`, which is `Date.now()` here but moves with a test's clock; a session already ending (the call refused) reads the system's. */
448function nowMs($: Api): Promise<number> {
449 return $.clock.now().catch(() => Date.now())
450}
451
452function redraw($: Api) {
453 $.ui.invalidate('ui.render')
454}
455
456/** A toast, at most one every few seconds so a burst of errors doesn't flood the screen. */
457function toast($: Api, text: string) {
458 const now = Date.now()
459 if (now - lastToastAt < TOAST_GAP_MS) return
460 lastToastAt = now
461 $.ui.toast(text.length > 140 ? text.slice(0, 139) + '…' : text, { timeoutMs: 6000 })
462}
463
464function notLaravel(): string {
465 return 'No `artisan` file in this directory, so this does not look like a Laravel project. Start Claude Code from the project root.'
466}
467
468async function openPane($: Api, on: Tab) {
469 tab = on
470 if (on === 'log') unseenErrors = 0
471 if (on === 'services') for (const svc of Object.values(services)) if (svc.crashed) svc.crashed.seen = true
472 if (on === 'about' && !about) void loadAbout($)
473 // Again after a failed load, so a fixed app shows its routes
474 if (on === 'routes' && !routeList) void loadRouteList($)
475 if (on === 'queue') void refreshQueue($)
476 paneOpen = true
477 await $.ui.open({ id: PANE, title: 'lara-cc', focus: true, closeOnEscape: true })
478 redraw($)
479 if (on === 'routes') void (routesLoading ?? Promise.resolve()).then(() => focusRoute($))
480}
481
482// ─── Settings (kept between sessions) ──────────────────────────────────────
483
484async function loadSettings($: Api) {
485 const saved = await $.store.get('settings')
486 if (saved && typeof saved === 'object') settings = { ...DEFAULT_SETTINGS, ...(saved as Partial<Settings>) }
487}
488
489async function toggle($: Api, key: 'autoRestartQueue' | 'pintAfterTurn' | 'lintAfterTurn' | 'showBand' | 'errorToasts') {
490 settings = { ...settings, [key]: !settings[key] }
491 redraw($)
492 await $.store.set('settings', settings)
493}
494
495async function toggleAutostart($: Api, name: string) {
496 const list = settings.autostart[projectDir] ?? []
497 const next = list.includes(name) ? list.filter(n => n !== name) : [...list, name]
498 settings = { ...settings, autostart: { ...settings.autostart, [projectDir]: next } }
499 redraw($)
500 await $.store.set('settings', settings)
501}
502
503// ─── PHP version ───────────────────────────────────────────────────────────
504
505/** The binary and version a PHP launch (`['php']`, `['herd', 'php']`) reports, or undefined when it doesn't run or answers oddly. */
506async function probePhp($: Api, argv: string[]): Promise<{ binary: string; version: string } | undefined> {
507 try {
508 const r = await $.process.run([...argv, ...PHP_OPTIONS, '-r', 'echo PHP_BINARY, "\\n", PHP_VERSION;'], { timeoutMs: 15_000 })
509 // The last two lines, should something print before them
510 const [binary = '', version = ''] = r.stdout.trim().split(/\r?\n/).slice(-2).map(s => s.trim())
511 if (r.exitCode !== 0 || !binary.startsWith('/') || !/^\d+\.\d+\.\d+/.test(version) || !(await $.fs.exists(binary))) return undefined
512 return { binary, version }
513 } catch {
514 return undefined
515 }
516}
517
518/** The absolute path `which` finds for `name` on PATH, or ''. */
519async function onPath($: Api, name: string): Promise<string> {
520 const r = await $.process.run(['which', name], { timeoutMs: 5_000 }).catch(() => undefined)
521 const path = r?.exitCode === 0 ? r.stdout.trim() : ''
522 return path.startsWith('/') && (await $.fs.exists(path)) ? path : ''
523}
524
525const dirOf = (path: string) => path.slice(0, path.lastIndexOf('/'))
526
527/**
528 * When the `php` on PATH is Herd's, use the binary Herd serves this folder with instead (the site's isolated
529 * version), so artisan, composer and Pint run on the same PHP as the site. A PATH php from elsewhere (Homebrew,
530 * asdf) was chosen on purpose and stays. Herd is asked once: going through `herd php` on every call adds ~0.5s,
531 * and it doesn't pass signals on, so stopping a queue worker would leave PHP running.
532 * Asks only about this folder: reads no site list and no .env.
533 */
534async function detectPhp($: Api) {
535 const project = projectRun
536 const [herd, shell, hasHerd] = await Promise.all([probePhp($, ['herd', 'php']), probePhp($, ['php']), onPath($, 'herd')])
537 if (project !== projectRun) return
538 if (!herd) {
539 const using = shell ? `PHP ${shell.version} from PATH` : ''
540 phpInfo = hasHerd ? `Herd didn't say which PHP serves this folder${using ? `; using ${using}` : ''}` : using
541 return
542 }
543 if (shell && dirOf(shell.binary) !== dirOf(herd.binary)) {
544 phpInfo = `PHP ${shell.version} from PATH (not Herd's php, so Herd's site version isn't used)`
545 return
546 }
547 phpBin = herd.binary
548 phpInfo = `PHP ${herd.version} · Herd` + (shell && shell.binary !== herd.binary ? ` (isolated; php in your shell is ${shell.version})` : '')
549
550 // As `herd composer` does, run Composer on the site's PHP; only when it really is a PHP script (not an asdf/mise shim)
551 const composer = await onPath($, 'composer')
552 if (!composer) return
553 const r = await $.process.run([herd.binary, composer, '--version', '--no-ansi'], { timeoutMs: 15_000 }).catch(() => undefined)
554 if (project === projectRun && r?.exitCode === 0 && /^Composer version/m.test(r.stdout)) composerArgv = [herd.binary, ...PHP_OPTIONS, composer]
555}
556
557/** `argv` with php, composer and Pint run on the PHP that serves this folder, with PHP_OPTIONS. */
558function withPhp(argv: string[]): string[] {
559 const [cmd = '', ...rest] = argv
560 if (cmd === 'php') return [phpBin, ...PHP_OPTIONS, ...rest]
561 if (cmd === 'composer') return [...composerArgv, ...rest]
562 if (cmd === 'vendor/bin/pint' && phpBin !== 'php') return [phpBin, ...PHP_OPTIONS, cmd, ...rest]
563 return argv
564}
565
566/** `$.process.run`, once the PHP binary is known, with `withPhp` applied. */
567async function runPhp($: Api, argv: string[], init: { timeoutMs: number }) {
568 await phpReady
569 return $.process.run(withPhp(argv), init)
570}
571
572// ─── One-shot commands ─────────────────────────────────────────────────────
573
574/** Run a command, keep its output for the Commands tab, and return it. `resolved`: the artisan command's full name, when it was typed short (`view:ca`). */
575async function runCommand($: Api, label: string, argv: string[], timeoutMs = 600_000, whole = false, resolved?: string): Promise<LastRun> {
576 const isTests = isTestCommand(argv.join(' '))
577 const project = projectRun
578 const edits = phpEdits
579 const ledger = editCount
580 lastRun = { label, isRunning: true, output: '' }
581 if (isTests) testsRunning = true
582 redraw($)
583 let run: LastRun
584 try {
585 const r = await runPhp($, argv, { timeoutMs })
586 const output = stripAnsi([r.stdout, r.stderr].filter(Boolean).join('\n')).trim()
587 run = { label, isRunning: false, exitCode: r.exitCode, output: tail(output, whole ? 200_000 : 8000), raw: tail(output, 200_000), whole }
588 if (isTests && project === projectRun) noteTestRun($, label, output, edits, ledger, argv.join(' '), r.exitCode)
589 if (project === projectRun) noteArtisanRan(resolved ?? (argv[1] === 'artisan' ? argv[2] : undefined), r.exitCode, ledger)
590 } catch (err) {
591 run = { label, isRunning: false, exitCode: -1, output: 'Could not run `' + argv.join(' ') + '`: ' + String(err) }
592 }
593 // The caller still gets the result; the Commands tab doesn't show it in another folder
594 if (project === projectRun) {
595 lastRun = run
596 if (isTests) testsRunning = false
597 }
598 redraw($)
599 // optimize:clear, composer install, artisan up… change what the checks see
600 void checkHealth($)
601 return run
602}
603
604/** Send `text` to Claude as the person's message: the ask Claude buttons. */
605function sendToClaude($: Api, text: string) {
606 $.prompt.submit({ text, asUser: true }).catch(err => $.ui.toast('Could not send to Claude: ' + String(err)))
607}
608
609/** A tool result for Claude with its text redacted; an error stays one (as a deny, which Claude reads as an error). */
610function redactedResult(result: { text?: string; result?: unknown; isError?: true; deny?: string }) {
611 if (result.deny) return { deny: result.deny }
612 return result.isError ? { deny: redact(resultText(result)) } : { result: redact(resultText(result)) }
613}
614
615/**
616 * What a tool call returned as text: what the model reads (`text`, set by core), else the record itself when a
617 * hook answered: a string, MCP text blocks, or Bash's stdout and stderr.
618 */
619function resultText(result: { text?: string; result?: unknown }): string {
620 if (result.text !== undefined) return result.text
621 const record = result.result
622 if (typeof record === 'string') return record
623 if (Array.isArray(record)) return record.map(b => (b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' ? (b as { text: string }).text : '')).join('\n')
624 if (record && typeof record === 'object') {
625 const { stdout, stderr } = record as { stdout?: unknown; stderr?: unknown }
626 if (typeof stdout === 'string' || typeof stderr === 'string') return [stdout, stderr].filter(t => typeof t === 'string').join('\n')
627 return JSON.stringify(record)
628 }
629 return ''
630}
631
632/**
633 * Remember a test run's result for the band and the Tests tab. Output with no test summary is ignored, unless the run
634 * failed (`exitCode`): it died before printing one (a dd() left in, a fatal while loading, out of memory), which is a
635 * failure, not "not run". `edits`:
636 * phpEdits when the run started, so an edit Claude made while it ran marks it as not covering the code; `ledger`:
637 * editCount then, which any tracked edit moves. `command`: all of what ran (the label may be cut), for its scope.
638 */
639function noteTestRun($: Api, label: string, output: string, edits: number, ledger: number, command: string, exitCode?: number) {
640 const parsed = parseTestRun(stripAnsi(output))
641 if (!parsed && !exitCode) return
642 const run = parsed ?? { passed: 0, failed: 0, skipped: 0, failures: [] }
643 lastTest = {
644 ...run,
645 label,
646 at: Date.now(),
647 ledger,
648 scope: testScope(command),
649 ...(parsed ? (exitCode === undefined ? {} : { exitCode }) : { crashed: exitCode }),
650 ...(edits !== phpEdits ? { editedSince: true as const } : {}),
651 }
652 redraw($)
653}
654
655/** Remember that route:list or view:cache ran, and whether it worked: the Routes and Views rows of the report read it. */
656function noteArtisanRan(name: string | undefined, exitCode: number, ledger: number) {
657 if (name === 'route:list' || name === 'view:cache') artisanRan[name] = { seq: ledger, ok: exitCode === 0, at: Date.now() }
658}
659
660/** Send one failing test to Claude to fix: the Tests tab's button. Redacted, like everything sent. */
661function askAboutTest($: Api, f: TestFailure) {
662 const text = ['This test fails in my Laravel app. Find the cause and fix it, then run it again.', '', `Test: ${f.name}`, '', '```', redact(f.detail.join('\n')), '```'].join('\n')
663 sendToClaude($, text)
664}
665
666/** Send every failing test of the last run to Claude. */
667function askAboutTests($: Api, failures: TestFailure[]) {
668 const blocks = failures.slice(0, 10).map(f => `Test: ${f.name}\n\`\`\`\n${redact(f.detail.slice(0, 15).join('\n'))}\n\`\`\``)
669 const text = [`${plural(failures.length, 'test')} fail in my Laravel app. Find the causes and fix them, then run the tests again.`, '', ...blocks].join('\n\n')
670 sendToClaude($, text)
671}
672
673/** A run's result for Claude (tool results and command output in the transcript), with secrets redacted. */
674function formatRun(run: LastRun): string {
675 const head = redact(run.exitCode === 0 ? `✓ ${run.label}` : `✗ ${run.label} (exit ${run.exitCode})`)
676 // Redacted whole before the cut, so a cut can't part a key from its value
677 const body = tail(redact(run.raw ?? run.output))
678 return body ? `${head}\n${body}` : head
679}
680
681/**
682 * Whether artisan `argv` is safe to run for Claude. Artisan resolves the name itself (`help <name>`), so
683 * abbreviations and odd casing can't slip past DESTRUCTIVE; a name it can't resolve comes back as `error`.
684 */
685async function checkArtisan(
686 $: Api,
687 argv: string[],
688): Promise<{ resolved?: string; refused?: string; error?: string; impact?: string; shownToPerson?: true }> {
689 const options = refusedOptions(argv, DESTRUCTIVE_OPTIONS)
690 if (options.length) return { refused: `${options.join(' ')} can destroy data or point at another environment` }
691 const { resolved, error } = await resolveArtisan($, argv)
692 if (error) return { error }
693 if (!resolved) return {}
694 return { resolved, ...(await artisanSafety($, resolved)) }
695}
696
697/** What checkArtisan does with an artisan command artisan has resolved, by the 0.11 lists: Claude's tool and the health tool's suggestions both read it. */
698async function artisanSafety($: Api, resolved: string): Promise<{ refused?: string; impact?: string; shownToPerson?: true }> {
699 if (SHOWN_TO_PERSON.includes(resolved)) return { shownToPerson: true }
700 if (CONFIDENTIAL.includes(resolved)) return { refused: `${resolved} prints configuration secrets (passwords, keys)` }
701 if (DESTRUCTIVE.includes(resolved)) {
702 return { refused: resolved === 'tinker' ? 'tinker runs arbitrary PHP' : `${resolved} can destroy data` }
703 }
704 if (CONTAINED.test(resolved)) return {}
705 // Tests stay in the folder only when they can't touch a real database (RefreshDatabase resets the one configured)
706 if (resolved === 'test') {
707 const xml = await $.fs.read('phpunit.xml').catch(() => $.fs.read('phpunit.xml.dist').catch(() => ''))
708 if (testsUseMemoryDb(xml)) return {}
709 return { impact: "runs the test suite, and phpunit.xml doesn't send it to an in-memory database, so RefreshDatabase and the like can reset or fill a real one" }
710 }
711 return {
712 impact: IMPACTFUL[resolved] ?? "isn't one lara-cc knows to stay inside the project folder, so it may reach the database, queue, cache or network",
713 }
714}
715
716/** An artisan step to suggest to Claude, saying what its tool will do (artisanSafety), so the suggestion can't promise otherwise. */
717async function artisanStep($: Api, commands: string[]): Promise<string> {
718 const safety = await Promise.all(commands.map(c => artisanSafety($, c)))
719 const how = safety.some(s => s.refused) ? 'refused; ask the user' : safety.some(s => s.impact || s.shownToPerson) ? 'asks the user first' : 'runs without asking'
720 return `artisan tool: ${commands.map(c => '"' + c + '"').join(', then ')} (${how})`
721}
722
723/**
724 * Run artisan for the conversation (Claude's tool, /lara-artisan): the output, redacted, with what two everyday
725 * commands print as a matter of course taken out: `about`'s app URL, and route:list's domains (it runs with
726 * --json so the domain is a field to drop, whatever options were asked for).
727 */
728async function artisanForConversation($: Api, args: string, resolved: string | undefined, timeoutMs = 600_000): Promise<string> {
729 const argv = splitArgs(args)
730 // Generators write files lara-cc doesn't see (the report says so)
731 if (resolved && /^(make:|filament:make-)/.test(resolved)) artisanMade++
732 if (resolved === 'about') {
733 // Read as JSON, whatever format was asked for, so only allowed values are shown (see aboutForClaude)
734 const r = await runPhp($, ['php', 'artisan', ...argv.filter(a => a !== '--json'), '--json', '--no-interaction'], { timeoutMs })
735 const text = aboutForClaude(r.stdout)
736 return text ? `✓ artisan ${redact(args)} (addresses and other values that could be confidential left out)\n${redact(text)}` : `✗ artisan ${redact(args)}\n${failureReason(r.stdout + '\n' + r.stderr)}`
737 }
738 if (resolved === 'route:list') {
739 const project = projectRun
740 const ledger = editCount
741 const r = await runPhp($, ['php', 'artisan', ...argv.filter(a => a !== '--json'), '--json', '--no-interaction'], { timeoutMs })
742 const routes = parseRouteList(r.stdout)
743 if (project === projectRun) artisanRan['route:list'] = { seq: ledger, ok: !!routes, at: Date.now() }
744 if (!routes) return `✗ artisan ${redact(args)}\n${failureReason(r.stdout + '\n' + r.stderr)}`
745 const verbose = argv.some(a => /^-v+$/.test(a))
746 const lines = routes.map(route => formatRoute(route) + (route.onDomain ? ' (own domain)' : '') + (verbose && route.middleware.length ? ` [${route.middleware.join(', ')}]` : ''))
747 const body = tail(redact(lines.join('\n')))
748 if (project === projectRun) lastRun = { label: 'artisan ' + args, isRunning: false, exitCode: r.exitCode, output: body }
749 redraw($)
750 return `✓ artisan ${redact(args)} (${plural(routes.length, 'route')}; domains left out)\n${body}`
751 }
752 const run = await runCommand($, 'artisan ' + args, ['php', 'artisan', ...argv, '--no-interaction'], timeoutMs, false, resolved)
753 return formatRun(run)
754}
755
756/** What Claude gets when the person says no to running something: not to try again, and how they'd run it. */
757const NOT_RUN = "The user chose not to run this. Don't retry it; if it's needed, ask them to run it themselves (e.g. with /lara-artisan)."
758
759/**
760 * Ask the person before Claude, or a command they didn't type, runs something IMPACTFUL. Shows what it does and
761 * where it lands as far as the mod knows (APP_ENV, and which of the app's servers are on this machine: driver and
762 * port, never a host or user). Dismissed, or nobody there to answer (`claude -p`), is no.
763 */
764async function mayRun($: Api, what: string, effect: string): Promise<boolean> {
765 const project = projectRun
766 // `about` and the servers are read together, a moment after start; until then, say so rather than "none found"
767 const env = about?.environment?.environment
768 const local = (connections ?? []).map(c => `${c.use} ${c.driver}:${c.port}`)
769 const where = !about
770 ? "lara-cc doesn't know APP_ENV or the app's servers (not read yet, or `artisan about` failed)."
771 : `APP_ENV is ${typeof env === 'string' ? env : 'unknown'}${aboutFailed ? ` (as read ${ago(aboutAt)} ago; artisan about has failed since)` : ''}; ` +
772 (connections === undefined
773 ? "lara-cc couldn't read which servers it connects to."
774 : local.length
775 ? `servers on this machine it connects to: ${local.join(', ')}.`
776 : 'lara-cc found none of its servers on this machine (it may use SQLite, files or a server elsewhere).')
777 const question = `Claude (or a command you didn't type) wants to run ${what}, which ${effect}. ${where} Run it?`
778 const answer = await $.ui.ask(question, { header: 'lara-cc', options: ['Run it', "Don't run"] }).catch(() => "Don't run")
779 // A yes is for the folder the question was about
780 return answer === 'Run it' && project === projectRun
781}
782
783/**
784 * Run a SHOWN_TO_PERSON command for the person: its output goes to the Commands tab, and what comes back for the
785 * conversation says only that it was shown. `ask`: Claude (or a command the person didn't type) wants it, so the
786 * person says yes first; dismissed or nobody there to answer is no.
787 */
788async function showToPerson($: Api, args: string, ask: boolean): Promise<string> {
789 if (ask) {
790 const question =
791 `Claude wants to see php artisan ${args}. Its output can hold passwords, keys, hosts and users, so lara-cc shows it to you in its pane and doesn't send it to Claude. Show it?`
792 const project = projectRun
793 const answer = await $.ui.ask(question, { header: 'Privacy', options: ['Show me', "Don't run"] }).catch(() => "Don't run")
794 // A yes is for the folder the question was about
795 if (answer !== 'Show me' || project !== projectRun) return NOT_RUN
796 }
797 const run = await runCommand($, 'artisan ' + args, ['php', 'artisan', ...splitArgs(args), '--no-interaction'], 600_000, true)
798 await openPane($, 'commands').catch(() => {})
799 return (
800 `${run.exitCode === 0 ? '✓' : '✗'} artisan ${redact(args)}: shown to the user in the lara-cc pane (Commands tab). ` +
801 "The output isn't in this conversation because it can hold secrets; ask the user for what you need from it, without the secrets."
802 )
803}
804
805/** For a service start: what the process does, when it's one that needs asking (queue worker, Horizon, scheduler; not Vite). */
806function startImpact(name: string): string | undefined {
807 const argv = services[name]?.argv ?? []
808 return argv[1] === 'artisan' ? IMPACTFUL[argv[2] ?? ''] : undefined
809}
810
811/** What a service action does that needs the person's yes: starting the worker or scheduler (a restart of what isn't running starts it). */
812function serviceImpact(name: string, action: string): string | undefined {
813 const starts = !services[name]?.run && (action === 'start' || action === 'restart')
814 return starts ? startImpact(name) : undefined
815}
816
817/** A service step to suggest to Claude, saying what guardedServiceAction will do. */
818function serviceStep(name: string, action: 'start' | 'restart'): string {
819 return `service tool: ${name} ${action} (${serviceImpact(name, action) ? 'asks the user first' : 'runs without asking'})`
820}
821
822/** A finding's next steps for Claude's health tool: what Claude's tools can do, then the person's button. */
823function nextSteps(claude: string | undefined, button: string | undefined): string {
824 const steps = [claude ?? (button ? 'no tool of yours does this' : 'no tool of yours does this; tell the user'), button ? `the user's button "${button}"` : '']
825 return ' next: ' + steps.filter(Boolean).join(' · ')
826}
827
828/**
829 * serviceAction for anyone but the person typing or pressing: a start (or a restart that starts) of a process
830 * that runs jobs or tasks asks first. Stopping, and restarting what already runs, are left to Claude, so it can
831 * restart the worker after changing job code.
832 */
833async function guardedServiceAction($: Api, name: string, action: string): Promise<string> {
834 const impact = serviceImpact(name, action)
835 if (impact && !(await mayRun($, (services[name]?.argv ?? []).join(' '), impact))) return NOT_RUN
836 return serviceAction($, name, action)
837}
838
839/** retryFailed for anyone but the person: asks first, naming how many jobs `all` is. */
840async function guardedRetry($: Api, id: string): Promise<string> {
841 const what = id === 'all' ? `queue:retry all (${snapshot ? plural(snapshot.failedTotal, 'failed job') : 'every failed job'})` : `queue:retry ${id}`
842 if (!(await mayRun($, what, IMPACTFUL['queue:retry']!))) return NOT_RUN
843 return retryFailed($, id)
844}
845
846/** Typed by the person (at the terminal or through Remote Control), not relayed from another session, channel or plugin. */
847/** The full name artisan resolves the command in `argv` to (`migr:fr` is migrate:fresh); none for bare `artisan`. */
848async function resolveArtisan($: Api, argv: string[]): Promise<{ resolved?: string; error?: string }> {
849 const name = artisanCommandName(argv)
850 if (name === undefined) return {}
851 const r = await runPhp($, ['php', 'artisan', 'help', name, '--format=json', '--no-interaction'], { timeoutMs: 60_000 })
852 const resolved = r.exitCode === 0 ? helpName(r.stdout) : undefined
853 if (resolved) return { resolved }
854 const output = stripAnsi([r.stdout, r.stderr].filter(Boolean).join('\n')).trim()
855 // Goes to Claude: the app failing to boot can print config, hosts and users
856 return { error: `✗ artisan could not resolve "${name}"\n${tail(redact(output), 2000)}` }
857}
858
859function fromUser(origin: { kind: string } | undefined): boolean {
860 return origin?.kind === 'composer' || origin?.kind === 'bridge'
861}
862
863// ─── Long-running services ─────────────────────────────────────────────────
864
865function onServiceOutput($: Api, svc: Service, text: string) {
866 const parts = (svc.partial + stripAnsi(text)).split('\n')
867 svc.partial = parts.pop() ?? ''
868 for (const line of parts) {
869 if (line.trim() === '') continue
870 svc.lines.push(line)
871 if (svc.name === 'queue') onWorkerLine($, line)
872 }
873 if (svc.lines.length > MAX_SERVICE_LINES) svc.lines.splice(0, svc.lines.length - MAX_SERVICE_LINES)
874 redraw($)
875}
876
877async function startService($: Api, name: string): Promise<string> {
878 const svc = services[name]
879 if (!svc) return `Unknown service "${name}". Known: ${Object.keys(services).join(', ')}`
880 const project = projectRun
881 await phpReady
882 if (project !== projectRun) return `${svc.label} not started: the session moved to another folder.`
883 // Nothing awaits between this check and the spawn, so two quick starts can't both get through
884 if (svc.run) return `${svc.label} is already running.`
885
886 const argv = withPhp(svc.argv)
887 svc.crashed = undefined
888 const stream = $.process.spawn({ argv })
889 svc.lines.push(`$ ${argv.join(' ')}`)
890 svc.startedAt = Date.now()
891 svc.startedSeq = editCount
892 svc.partial = ''
893
894 let finish = () => {}
895 const run: Run = { stream, done: new Promise<void>(resolve => (finish = resolve)) }
896 svc.run = run
897
898 // The loop is the child's life: it keeps running after this function returns.
899 void (async () => {
900 try {
901 let step = await stream.next()
902 while (!step.done) {
903 // A stop that timed out has let go of the run: what it still prints isn't the service's any more
904 if (svc.run === run) onServiceOutput($, svc, step.value.text)
905 step = await stream.next()
906 }
907 // A stream closed by stopService ends without a result
908 const end = step.value as ProcessSpawnResult | undefined
909 // A stop that let go of the run (timed out, or the session moved on) has said its piece already
910 if (svc.run === run) {
911 if (!end || end.signal) svc.lines.push('■ stopped')
912 else svc.lines.push(`■ exited with code ${end.code}`)
913 if (end?.code) {
914 svc.crashed = { code: end.code, seen: paneOpen && tab === 'services' }
915 toast($, `${svc.label} exited with code ${end.code}`)
916 }
917 }
918 } catch (err) {
919 if (svc.run === run) svc.lines.push('■ ' + String(err))
920 }
921 if (svc.run === run) {
922 svc.run = undefined
923 svc.startedAt = undefined
924 }
925 finish()
926 redraw($)
927 // Vite leaves public/hot behind when it dies
928 void checkHealth($)
929 })()
930
931 redraw($)
932 return `${svc.label} started: ${argv.join(' ')}`
933}
934
935async function stopService($: Api, name: string): Promise<string> {
936 const svc = services[name]
937 if (!svc) return `Unknown service "${name}".`
938 // A start waiting on phpReady goes first, so this sees (and stops) what it spawns
939 await phpReady
940 const run = svc.run
941 // Only what this session started: one run elsewhere is out of its sight
942 if (!run) return `${svc.label} isn't running in this session, so there's nothing to stop; lara-cc can't stop one run elsewhere.`
943 svc.lines.push('… stopping')
944 redraw($)
945 // Closing the stream kills the child. Don't wait forever on a quiet process.
946 void run.stream.return({ code: null, signal: 'SIGTERM' }).catch(() => {})
947 await Promise.race([run.done, $.clock.sleep(5000)])
948 if (svc.run === run) {
949 svc.run = undefined
950 svc.startedAt = undefined
951 svc.lines.push('■ stop requested (process may still be finishing)')
952 }
953 redraw($)
954 return `${svc.label} stopped.`
955}
956
957async function restartService($: Api, name: string): Promise<string> {
958 if (services[name]?.run) await stopService($, name)
959 return (await startService($, name)).replace('started', 'restarted')
960}
961
962async function serviceAction($: Api, name: string, action: string): Promise<string> {
963 const svc = services[name]
964 if (!svc) return `Unknown service "${name}". Known: ${Object.keys(services).join(', ')}`
965 if (action === 'start') return startService($, name)
966 if (action === 'stop') return stopService($, name)
967 if (action === 'restart') return restartService($, name)
968 if (action === 'logs') return `${svc.label}, last 60 lines:\n` + (redact(svc.lines.slice(-60).join('\n')) || '(no output)')
969 const state = svc.run
970 ? `${svc.label} is running (${ago(svc.startedAt ?? Date.now())}), started by this session.`
971 : `${svc.label} is not running in this session; lara-cc can't see one run elsewhere.`
972 return name === 'queue' ? state + '\n' + queueSummary() : state
973}
974
975// ─── Queue monitoring ──────────────────────────────────────────────────────
976
977function onWorkerLine($: Api, line: string) {
978 const ev = parseWorkerLine(line)
979 if (!ev) return
980 if (ev.status === 'RUNNING') {
981 jobs.push({ job: ev.job, time: ev.time, status: 'running' })
982 } else {
983 const ms = durationMs(ev.duration)
984 const status = ev.status === 'DONE' ? 'done' : 'failed'
985 let i = jobs.length - 1
986 while (i >= 0 && !(jobs[i]?.status === 'running' && jobs[i]?.job === ev.job)) i--
987 const job = jobs[i]
988 if (job) Object.assign(job, { status, ms })
989 else jobs.push({ job: ev.job, time: ev.time, status, ms })
990 if (status === 'done') {
991 stats.done += 1
992 stats.totalMs += ms ?? 0
993 } else {
994 stats.failed += 1
995 toast($, `Job failed: ${shortClass(ev.job)}`)
996 // The failed_jobs row is written just after the line; look again shortly
997 $.clock.after(1500, () => void refreshQueue($))
998 }
999 }
1000 if (jobs.length > 100) jobs.splice(0, jobs.length - 100)
1001}
1002
1003/** Read the queue state. One read at a time: a call while one runs gets that read (recheck then reads again). */
1004function refreshQueue($: Api): Promise<void> {
1005 if (!isLaravel) return Promise.resolve()
1006 if (snapshotLoad) return snapshotLoad
1007 const project = projectRun
1008 const loading = (async () => {
1009 try {
1010 const r = await runPhp($, ['php', '-r', snapshotPhp(queuesOf(services.queue.argv))], { timeoutMs: 30_000 })
1011 // Another folder's queue: detectProject has cleared the snapshot and the read in progress
1012 if (project !== projectRun) return
1013 const parsed = parseSnapshot(r.stdout)
1014 // Taken when it counted the jobs waiting; one that failed there keeps the last good read, so no count reads as current that isn't
1015 sizesRead = !!parsed && (!parsed.error || !!parsed.partial)
1016 if (parsed && sizesRead) {
1017 waitingAtTick ??= waiting(parsed)
1018 // A partial read's failed-job count is the PHP default 0, not a count: the last one stays (marked, see the band)
1019 snapshot = parsed.partial && snapshot ? { ...parsed, failedTotal: snapshot.failedTotal, failed: snapshot.failed } : parsed
1020 }
1021 if (parsed && !parsed.error) snapshotOkAt = Date.now()
1022 snapshotError = parsed ? (parsed.error ?? '') : clip(redact(stripAnsi(r.stderr || r.stdout).trim().split('\n')[0] ?? ''), 300) || 'Could not read the queue state.'
1023 snapshotKind = parsed ? parsed.errorKind || errorKind(parsed.error ?? '') : errorKind(r.stderr + '\n' + r.stdout)
1024 } catch (err) {
1025 if (project !== projectRun) return
1026 sizesRead = false
1027 snapshotError = String(err)
1028 snapshotKind = "it timed out or couldn't run"
1029 }
1030 snapshotAt = Date.now()
1031 // Only the backlog warning depends on the snapshot: swap it in without running every check
1032 const backlog = backlogCheck($)
1033 checks = [...checks.filter(c => !c.backlog), ...(backlog ? [backlog] : [])]
1034 redraw($)
1035 })().finally(() => {
1036 // Not one a later folder started (leaveProject let go of this one)
1037 if (snapshotLoad === loading) snapshotLoad = undefined
1038 })
1039 snapshotLoad = loading
1040 redraw($)
1041 return loading
1042}
1043
1044/** Jobs waiting across the snapshot's queues. */
1045function waiting(snap: QueueSnapshot): number {
1046 return Object.values(snap.sizes).reduce((n, size) => n + (Number(size) || 0), 0)
1047}
1048
1049/** `default: 3, emails: 1`: the snapshot's queues with jobs waiting. */
1050function waitingSizes(snap: QueueSnapshot): string {
1051 return Object.entries(snap.sizes).filter(([, n]) => n).map(([q, n]) => `${q}: ${n}`).join(', ')
1052}
1053
1054/** Fewer jobs wait than at the read before while this session runs no worker: something elsewhere is working the queue. */
1055function drainingNote(): string {
1056 if (services.queue.run || !snapshot || !sizesRead || waitingBefore === undefined || waiting(snapshot) >= waitingBefore) return ''
1057 return `Jobs waiting went from ${waitingBefore} to ${waiting(snapshot)} since the read before, so a worker elsewhere seems to be working the queue.`
1058}
1059
1060/** How the last queue read failed, for Claude: the kind only, since the message can name a host. */
1061function queueReadFailure(): string {
1062 return `${sizesRead ? "couldn't count the failed jobs" : 'failed'} (${snapshotKind || 'no reason given'})`
1063}
1064
1065/** Claude's answer when the failed jobs aren't known (no read, or a read that failed or couldn't count them). */
1066function queueUnread(): string {
1067 return `The queue read ${queueReadFailure()}; the full message is on lara-cc's Queue tab, shown only to the user.`
1068}
1069
1070async function retryFailed($: Api, id: string): Promise<string> {
1071 const run = await runCommand($, `queue:retry ${id}`, ['php', 'artisan', 'queue:retry', id], 60_000)
1072 await refreshQueue($)
1073 return formatRun(run)
1074}
1075
1076async function forgetFailed($: Api, id: string): Promise<string> {
1077 const run = await runCommand($, `queue:forget ${id}`, ['php', 'artisan', 'queue:forget', id], 60_000)
1078 await refreshQueue($)
1079 return formatRun(run)
1080}
1081
1082function queueSummary(): string {
1083 const avg = stats.done ? Math.round(stats.totalMs / stats.done) : 0
1084 const lines = [
1085 `This session: ${stats.done} done, ${stats.failed} failed${stats.done ? `, avg ${avg}ms` : ''}.`,
1086 ]
1087 if (snapshot) {
1088 lines.push(`Connection: ${snapshot.connection}${snapshot.connection === 'sync' ? ' (jobs run inline; no worker needed)' : ''}`)
1089 lines.push('Pending: ' + (Object.entries(snapshot.sizes).map(([q, n]) => `${q}=${n}`).join(', ') || 'unknown'))
1090 lines.push(`Failed jobs stored: ${sizesRead && snapshotError ? 'unknown' : snapshot.failedTotal}`)
1091 }
1092 const draining = drainingNote()
1093 if (draining) lines.push(draining)
1094 // The kind only: the message can name a host, and it's on the Queue tab for the person
1095 if (snapshotError) {
1096 lines.push(`Note: the last queue read ${queueReadFailure()}; the full message is on lara-cc's Queue tab, shown only to the user.`)
1097 }
1098 const recent = jobs.slice(-10).map(j => ` ${j.status === 'done' ? '✓' : j.status === 'failed' ? '✗' : '…'} ${j.time} ${j.job}${j.ms !== undefined ? ` ${Math.round(j.ms)}ms` : ''}`)
1099 if (recent.length) lines.push('Recent jobs:', ...recent)
1100 return lines.join('\n')
1101}
1102
1103function formatFailed(list: FailedJob[], withTrace: number): string {
1104 if (!list.length) return 'No failed jobs.'
1105 return list
1106 .map((f, i) => {
1107 // Redacted whole, before the headline is cut to length
1108 const exception = redact(f.exception)
1109 const rest = i < withTrace ? exception.split('\n').slice(1, 13).filter(l => l.trim()) : []
1110 const trace = rest.length ? '\n' + rest.map(l => ' ' + l).join('\n') : ''
1111 return `- ${f.id} ${f.job} (queue ${f.queue}, failed ${f.failedAt})\n ${exceptionHeadline(exception)}${trace}`
1112 })
1113 .join('\n')
1114}
1115
1116async function askAboutFailedJob($: Api, f: FailedJob) {
1117 if (!(await mayShare($, 'this failed job and its exception'))) return
1118 const text = [
1119 'This queued job failed in my Laravel app. Find the cause and fix it. When fixed, retry it with the queue tool.',
1120 '',
1121 `Job: ${f.job}`,
1122 `Failed job id: ${f.id}`,
1123 `Queue: ${f.queue}, failed at ${f.failedAt}`,
1124 '',
1125 '```',
1126 redact(f.exception.split('\n').slice(0, 40).join('\n')),
1127 '```',
1128 ].join('\n')
1129 sendToClaude($, text)
1130}
1131
1132// ─── Log monitoring ────────────────────────────────────────────────────────
1133
1134/** The newest laravel*.log in storage/logs (single or daily channel). */
1135async function newestLog($: Api): Promise<string> {
1136 const dir = 'storage/logs'
1137 try {
1138 const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && /^laravel(-\d{4}-\d\d-\d\d)?\.log$/.test(f.name))
1139 let best = ''
1140 let bestTime = -1
1141 for (const f of files) {
1142 const s = await $.fs.stat(dir + '/' + f.name)
1143 if (s.mtimeMs > bestTime) {
1144 best = f.name
1145 bestTime = s.mtimeMs
1146 }
1147 }
1148 if (best) return dir + '/' + best
1149 } catch {
1150 // no storage/logs yet
1151 }
1152 return dir + '/laravel.log'
1153}
1154
1155/** The tail stopped (or is being replaced): the log isn't followed until the next one starts. */
1156function openLogGap(now: number) {
1157 const last = logGaps.at(-1)
1158 if (!logSince || (last && last.to === undefined)) return
1159 logGaps.push({ from: now })
1160 if (logGaps.length > 50) logGaps.shift()
1161}
1162
1163/** A tail started: the first one marks when following began here, a later one ends the gap since the last stopped. */
1164function closeLogGap(now: number) {
1165 logSince ||= now
1166 const last = logGaps.at(-1)
1167 if (last && last.to === undefined) last.to = now
1168}
1169
1170/** Follow the newest log file; called at start and every minute, so a new daily file is picked up. */
1171async function watchLog($: Api) {
1172 if (!isLaravel) return
1173 const project = projectRun
1174 let loaded = () => {}
1175 const ready = new Promise<void>(resolve => (loaded = resolve))
1176 const previous = logReady
1177 logReady = ready
1178 try {
1179 // One watch at a time, so two overlapping calls can't start two tails
1180 await previous
1181 const path = await newestLog($)
1182 if (project !== projectRun) return
1183 if (path === logPath && logRun) {
1184 const looked = await nowMs($)
1185 if (project === projectRun) logLookedAt = looked
1186 return
1187 }
1188 // Let go of the old tail at once (before anything is awaited), so what it still sends while the new file is read isn't taken for it
1189 const old = logRun
1190 logRun = undefined
1191 if (old) {
1192 void old.stream.return({ code: null, signal: 'SIGTERM' }).catch(() => {})
1193 // The new file may have begun at any time since the last look, and what it already holds is read as history,
1194 // not counted: the gap starts there, so an interval that touches it isn't called quiet
1195 const from = logLookedAt || (await nowMs($))
1196 if (project !== projectRun) return
1197 openLogGap(from)
1198 }
1199
1200 logPath = pathhooks/parse.ts 1042 lines1/**
2 * Pure helpers: no `$`, so they can live in their own file and be unit-tested.
3 */
4
5export type LogEntry = { time: string; env: string; level: string; message: string; detail: string[] }
6export type LogState = { entries: LogEntry[]; partial: string }
7export type WorkerEvent = { time: string; job: string; status: 'RUNNING' | 'DONE' | 'FAIL'; duration?: string }
8
9export const MAX_LOG_ENTRIES = 500
10const MAX_DETAIL_LINES = 80
11export const ERROR_LEVELS = ['ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY']
12export const WARNING_LEVELS = ['WARNING', 'NOTICE']
13
14export function stripAnsi(text: string): string {
15 return text.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\r/g, '')
16}
17
18/** Split "make:model Post -m --force" into argv, keeping quoted strings together. */
19export function splitArgs(input: string): string[] {
20 const out: string[] = []
21 for (const m of input.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) out.push(m[1] ?? m[2] ?? m[3] ?? '')
22 return out
23}
24
25/**
26 * The token artisan treats as the command name: the first one that isn't an option, as Symfony's
27 * `getFirstArgument` picks it (`-q migrate:fresh` runs migrate:fresh). Undefined when there is none.
28 */
29export function artisanCommandName(argv: string[]): string | undefined {
30 return argv.find(token => !token.startsWith('-'))
31}
32
33/**
34 * The tokens in `argv` that set one of `refused` (`--force`, `-f`): long options alone or as
35 * `--force=…`, short ones alone or inside a group like `-mf`.
36 */
37export function refusedOptions(argv: string[], refused: string[]): string[] {
38 return argv.filter(token => {
39 if (token.startsWith('--')) return refused.includes(token.split('=')[0]!)
40 if (token.startsWith('-')) return [...token.slice(1)].some(c => refused.includes('-' + c))
41 return false
42 })
43}
44
45/**
46 * Options for every PHP the mod starts: PHP's own warnings and errors go to stderr, so stdout carries only what
47 * the command prints. Laravel turns them off itself once booted; this covers what comes before (autoloading, a
48 * package that warns on a newer PHP) and after (shutdown).
49 */
50export const PHP_OPTIONS = ['-d', 'display_errors=stderr']
51
52/**
53 * The JSON object PHP printed, from `marker` (`{"routes"`) to the last `}`, so what app code prints around it
54 * (an `echo` in a service provider, a stray `dump()`) is skipped. Undefined when there's none.
55 */
56export function phpJson(stdout: string, marker = '{'): Record<string, unknown> | undefined {
57 const start = stdout.indexOf(marker)
58 const end = stdout.lastIndexOf('}')
59 if (start < 0 || end < start) return undefined
60 try {
61 const value: unknown = JSON.parse(stdout.slice(start, end + 1))
62 return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
63 } catch {
64 return undefined
65 }
66}
67
68/** The command name in `artisan help <name> --format=json` output. */
69export function helpName(stdout: string): string | undefined {
70 const name = phpJson(stdout)?.name
71 return typeof name === 'string' ? name : undefined
72}
73
74/**
75 * The last `max` characters of `text`, from a line start when one is near the cut. A cut can still part a key
76 * from its value, so text for Claude is redacted before it's cut, never after.
77 */
78export function tail(text: string, max = 8000): string {
79 if (text.length <= max) return text
80 const kept = text.slice(-max)
81 const nl = kept.indexOf('\n')
82 return '…' + (nl >= 0 && nl < 200 ? kept.slice(nl) : kept)
83}
84
85/** The first `max` characters of `text`, cut at a space or between path parts when one is near the end. Redact first, as for tail(). */
86export function clip(text: string, max: number): string {
87 if (text.length <= max) return text
88 const head = text.slice(0, max)
89 const at = Math.max(head.lastIndexOf(' '), head.search(/[^\w+=-][\w+=-]*$/))
90 return (at > max / 2 ? head.slice(0, at) : head) + '…'
91}
92
93/** `App\Jobs\SendInvoice` → `SendInvoice`, `App\Http\Controllers\X@y` → `X@y`. */
94export function shortClass(name: string): string {
95 return name.replace(/^App\\(Jobs|Listeners|Mail|Notifications|Http\\Controllers)\\/, '')
96}
97
98/**
99 * The kind of a PHP failure, for Claude where the message itself could name a host, user or path: the short class
100 * of the first exception or error named, else `PHP Parse error` / `PHP Fatal error`, and the SQLSTATE and driver
101 * code; '' when none is found. Never message text.
102 */
103export function errorKind(output: string): string {
104 const text = stripAnsi(output)
105 const sql = /SQLSTATE\[(\w{5})\](?:\s*\[(\d+)\])?/.exec(text)
106 const cls = /(?:^|[\s\\(])([A-Z]\w*(?:Exception|Error))\b/.exec(text)
107 const php = /\bPHP (Parse|Fatal) error\b/.exec(text)
108 const parts = [cls?.[1] ?? (php ? `PHP ${php[1]} error` : ''), sql ? `SQLSTATE[${sql[1]}]` + (sql[2] ? ` [${sql[2]}]` : '') : '']
109 return parts.filter(Boolean).join(', ')
110}
111
112// ─── storage/logs/*.log ────────────────────────────────────────────────────
113
114const LOG_HEADER = /^\[(\d{4}-\d\d-\d\d[ T][^\]]+)\] ([\w-]+)\.([A-Z]+): ?(.*)$/
115
116export function newLogState(): LogState {
117 return { entries: [], partial: '' }
118}
119
120/** Feed raw text from the log; returns the entries that started in it. */
121export function feedLog(state: LogState, text: string): LogEntry[] {
122 const lines = (state.partial + stripAnsi(text)).split('\n')
123 state.partial = lines.pop() ?? ''
124 const started: LogEntry[] = []
125 for (const line of lines) {
126 const m = line.match(LOG_HEADER)
127 if (m) {
128 const entry: LogEntry = { time: m[1] ?? '', env: m[2] ?? '', level: m[3] ?? '', message: m[4] ?? '', detail: [] }
129 state.entries.push(entry)
130 started.push(entry)
131 } else if (state.entries.length && line.trim() !== '') {
132 const last = state.entries[state.entries.length - 1]
133 if (last && last.detail.length < MAX_DETAIL_LINES) last.detail.push(line)
134 }
135 }
136 if (state.entries.length > MAX_LOG_ENTRIES) state.entries.splice(0, state.entries.length - MAX_LOG_ENTRIES)
137 return started
138}
139
140export function isError(entry: LogEntry): boolean {
141 return ERROR_LEVELS.includes(entry.level)
142}
143
144export function isWarning(entry: LogEntry): boolean {
145 return WARNING_LEVELS.includes(entry.level)
146}
147
148/** The message without the trailing JSON context Laravel appends. */
149export function headline(entry: LogEntry): string {
150 const cut = entry.message.search(/ \{"|\s\[\]\s*$/)
151 return (cut > 0 ? entry.message.slice(0, cut) : entry.message).trim()
152}
153
154/** A path relative to the project: strips `root` when given, else guesses from the usual Laravel folders. */
155export function projectPath(path: string, root = ''): string {
156 if (root && path.startsWith(root.replace(/[\/\\]$/, '') + '/')) return path.slice(root.replace(/[\/\\]$/, '').length + 1)
157 return path.replace(/^.*?[\/\\](app|bootstrap|config|database|resources|routes|src|tests|vendor|storage)[\/\\]/, '$1/')
158}
159
160/**
161 * Where an exception happened, e.g. `app/Models/User.php:42`. When it was thrown
162 * inside vendor/, the first stack frame in the project's own code is more useful.
163 */
164export function origin(entry: LogEntry, root = ''): string | undefined {
165 const m = entry.message.match(/ at (\/[^\s)]+?|[A-Z]:\\[^\s)]+?):(\d+)\)/)
166 if (!m) return undefined
167 const thrown = `${projectPath(m[1] ?? '', root)}:${m[2]}`
168 if (!thrown.startsWith('vendor/')) return thrown
169 for (const line of entry.detail) {
170 const frame = line.match(/^#\d+ (\S+?)\((\d+)\):/)
171 if (frame && !/[\/\\]vendor[\/\\]/.test(frame[1] ?? '')) return `${projectPath(frame[1] ?? '', root)}:${frame[2]}`
172 }
173 return thrown
174}
175
176/** One entry as text for Claude: header, origin, and the first stack lines. */
177export function formatEntry(entry: LogEntry, stackLines = 15, root = ''): string {
178 const where = origin(entry, root)
179 const prefix = root ? root.replace(/[\/\\]$/, '') + '/' : ''
180 const stack = entry.detail
181 .filter(l => /^#\d+ /.test(l))
182 .slice(0, stackLines)
183 .map(l => (prefix ? l.split(prefix).join('') : l))
184 return [`[${entry.time}] ${entry.level}: ${headline(entry)}`, where ? ` at ${where}` : '', ...stack.map(l => ' ' + l)]
185 .filter(Boolean)
186 .join('\n')
187}
188
189/** The time of day of a log timestamp: `2026-10-10 08:01:02` → `08:01:02`. */
190export function clock(time: string): string {
191 return time.match(/(\d\d:\d\d:\d\d)/)?.[1] ?? time
192}
193
194// ─── queue:work output ─────────────────────────────────────────────────────
195
196const WORKER_LINE = /^\s*(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\s+(\S+)\s*\.*\s*(?:([\d.,]+\s?(?:ms|s|m))\s+)?(RUNNING|DONE|FAIL)\s*$/
197
198/** A `queue:work` line such as `2026-10-10 08:00:01 App\Jobs\Foo .... 23.45ms DONE`. */
199export function parseWorkerLine(line: string): WorkerEvent | undefined {
200 const m = stripAnsi(line).match(WORKER_LINE)
201 if (!m) return undefined
202 return { time: m[1] ?? '', job: m[2] ?? '', status: m[4] as WorkerEvent['status'], duration: m[3]?.replace(/\s/g, '') }
203}
204
205/** `23.45ms` / `1.2s` / `2m` → milliseconds. */
206export function durationMs(duration: string | undefined): number | undefined {
207 const m = duration?.match(/^([\d.,]+)(ms|s|m)$/)
208 if (!m) return undefined
209 const n = Number((m[1] ?? '').replace(',', ''))
210 return m[2] === 'ms' ? n : m[2] === 's' ? n * 1000 : n * 60_000
211}
212
213/** The `--queue=a,b` names in a worker's argv. */
214export function queuesOf(argv: string[]): string[] {
215 const flag = argv.find(a => a.startsWith('--queue='))
216 return flag ? flag.slice('--queue='.length).split(',').filter(q => /^[\w:.-]+$/.test(q)) : []
217}
218
219// ─── Queue snapshot (failed jobs, sizes) ───────────────────────────────────
220
221export type FailedJob = { id: string; queue: string; job: string; failedAt: string; exception: string }
222export type QueueSnapshot = {
223 connection: string
224 sizes: Record<string, number>
225 failedTotal: number
226 failed: FailedJob[]
227 /** The message of what failed, for the person; `errorKind` is its exception's short class, for Claude. */
228 error?: string
229 errorKind?: string
230 /** The sizes were read but the failed jobs weren't: `failedTotal` and `failed` say nothing. */
231 partial?: true
232}
233
234/** PHP run with `php -r` from the project root: boots the app and prints the queue state as JSON. */
235export function snapshotPhp(queues: string[]): string {
236 const list = queues.filter(q => /^[\w:.-]+$/.test(q)).map(q => `'${q}'`).join(',')
237 return `
238require 'vendor/autoload.php';
239$app = require 'bootstrap/app.php';
240$app->make(Illuminate\\Contracts\\Console\\Kernel::class)->bootstrap();
241$c = config('queue.default');
242$queues = [${list}];
243if (!$queues) $queues = [config("queue.connections.$c.queue") ?: 'default'];
244$out = ['connection' => $c, 'sizes' => [], 'failedTotal' => 0, 'failed' => []];
245// The class's short name is safe for Claude; the message can name a host or user
246$fail = function (Throwable $e) use (&$out) { $out['error'] = $e->getMessage(); $out['errorKind'] = (new ReflectionClass($e))->getShortName(); };
247foreach ($queues as $q) { try { $out['sizes'][$q] = Illuminate\\Support\\Facades\\Queue::connection($c)->size($q); } catch (Throwable $e) { $fail($e); } }
248try {
249 $all = app('queue.failer')->all();
250 $out['failedTotal'] = count($all);
251 foreach (array_slice($all, 0, 25) as $f) {
252 $f = (array) $f;
253 $p = json_decode($f['payload'] ?? '{}', true) ?: [];
254 $out['failed'][] = ['id' => (string) ($f['uuid'] ?? $f['id'] ?? ''), 'queue' => (string) ($f['queue'] ?? ''), 'job' => (string) ($p['displayName'] ?? '?'), 'failedAt' => (string) ($f['failed_at'] ?? ''), 'exception' => mb_substr((string) ($f['exception'] ?? ''), 0, 4000)];
255 }
256} catch (Throwable $e) { if (!isset($out['error'])) $out['partial'] = true; $fail($e); }
257echo json_encode($out);
258`
259}
260
261/** Read the JSON from the snapshot's output. */
262export function parseSnapshot(stdout: string): QueueSnapshot | undefined {
263 const snap = phpJson(stdout, '{"connection"') as QueueSnapshot | undefined
264 // Only a plain class name reaches Claude: anything else (an anonymous class's name holds a path) is dropped
265 if (snap?.errorKind !== undefined && !/^[A-Za-z_]\w*$/.test(String(snap.errorKind))) snap.errorKind = ''
266 return snap
267}
268
269/** First line of a stored exception, e.g. `RuntimeException: Payment gateway down in /app/...:12`. */
270export function exceptionHeadline(exception: string): string {
271 return clip((exception.split('\n')[0] ?? '').replace(/^.*?\\(\w+(Exception|Error)):/, '$1:'), 300)
272}
273
274// ─── Health checks ─────────────────────────────────────────────────────────
275
276type ComposerPackage = { name: string; version: string }
277
278/**
279 * Packages whose installed version differs from composer.lock (changed, missing, or installed but no longer
280 * locked). `installedJson` is vendor/composer/installed.json: an object in Composer 2, a list in Composer 1.
281 */
282export function composerDrift(lockJson: string, installedJson: string): string[] {
283 const lock = JSON.parse(lockJson) as { packages?: ComposerPackage[]; 'packages-dev'?: ComposerPackage[] }
284 const installed = JSON.parse(installedJson) as { packages?: ComposerPackage[]; dev?: boolean } | ComposerPackage[]
285 const list = Array.isArray(installed) ? installed : (installed.packages ?? [])
286 // `composer install --no-dev` leaves the dev packages out on purpose
287 const dev = Array.isArray(installed) || installed.dev !== false
288 const want = new Map([...(lock.packages ?? []), ...(dev ? (lock['packages-dev'] ?? []) : [])].map(p => [p.name, p.version]))
289 const have = new Map(list.map(p => [p.name, p.version]))
290 return [...new Set([...want.keys(), ...have.keys()])].filter(name => want.get(name) !== have.get(name)).sort()
291}
292
293type NpmEntry = { version?: string; optional?: boolean; devOptional?: boolean; dev?: boolean }
294
295/**
296 * Packages whose installed version differs from package-lock.json, read from npm's own record of what it
297 * installed (node_modules/.package-lock.json). Not counted as missing: optional packages (other platforms'
298 * builds are never installed), dev packages after `npm install --omit=dev`, and workspace packages, which
299 * live outside node_modules.
300 */
301export function npmDrift(lockJson: string, installedJson: string): string[] {
302 const lock = (JSON.parse(lockJson) as { packages?: Record<string, NpmEntry> }).packages ?? {}
303 const have = (JSON.parse(installedJson) as { packages?: Record<string, NpmEntry> }).packages ?? {}
304 const deps = Object.entries(lock).filter(([path]) => path.includes('node_modules/'))
305 // An install that left out every dev package did so on purpose
306 const omitDev = deps.some(([, e]) => e.dev) && !deps.some(([path, e]) => e.dev && path in have)
307 const out = new Set<string>()
308 for (const [path, entry] of deps) {
309 const got = have[path]
310 if (got) {
311 if (got.version !== entry.version) out.add(path)
312 } else if (!entry.optional && !entry.devOptional && !(omitDev && entry.dev)) out.add(path)
313 }
314 for (const path of Object.keys(have)) if (path.includes('node_modules/') && !(path in lock)) out.add(path)
315 return [...out].map(path => path.replace(/^.*node_modules\//, '')).sort()
316}
317
318/**
319 * The origin in public/hot when it's a dev server on this machine (localhost, a loopback address, or a
320 * `.test`/`.localhost` host), else undefined: the mod only ever probes local servers.
321 */
322export function localHotUrl(text: string): string | undefined {
323 let url: URL
324 try {
325 url = new URL(text.trim())
326 } catch {
327 return undefined
328 }
329 if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
330 const host = url.hostname.replace(/^\[|\]$/g, '')
331 const local = ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(host) || host.endsWith('.test') || host.endsWith('.localhost')
332 return local ? url.origin : undefined
333}
334
335/** The port of a dev server origin from localHotUrl, with the scheme's default when none is given. */
336export function hotPort(origin: string): number {
337 const url = new URL(origin)
338 return Number(url.port) || (url.protocol === 'https:' ? 443 : 80)
339}
340
341export type HerdService = { type: string; name: string; port: number; version: string }
342
343/**
344 * Herd's installed services (MySQL, Redis…) from its services.plist, converted to JSON: type, name, port
345 * and version only. An entry without a plain type or a valid port is left out; unreadable JSON is none.
346 */
347export function herdServices(json: string): HerdService[] {
348 let list: unknown
349 try {
350 list = JSON.parse(json)
351 } catch {
352 return []
353 }
354 if (!Array.isArray(list)) return []
355 return list.flatMap((s: Record<string, unknown>) => {
356 const port = Number(s?.port)
357 if (typeof s?.type !== 'string' || !/^[a-z0-9]+$/.test(s.type) || !Number.isInteger(port) || port < 1 || port > 65535) return []
358 return [{ type: s.type, name: typeof s.name === 'string' ? s.name : s.type, port, version: typeof s.version === 'string' ? s.version : '' }]
359 })
360}
361
362/**
363 * PHP run with `php -r` from the project root: the driver and port of each server on this machine that the
364 * app's default database, cache, queue, session, broadcasting and Scout config connect to. Loads config without
365 * booting providers, so a server that's down can't stop it. Only those two values leave PHP: no credentials,
366 * usernames, URLs or remote hostnames ever reach the mod or Claude.
367 */
368export function connectionsPhp(): string {
369 return `
370require 'vendor/autoload.php';
371$app = require 'bootstrap/app.php';
372$app->bootstrapWith([Illuminate\\Foundation\\Bootstrap\\LoadEnvironmentVariables::class, Illuminate\\Foundation\\Bootstrap\\LoadConfiguration::class]);
373$out = [];
374$add = function ($use, $driver, $c, $port) use (&$out) {
375 if (!is_array($c) || !is_string($driver)) return;
376 $host = $c['host'] ?? '127.0.0.1';
377 if (is_array($host)) $host = reset($host);
378 $port = $c['port'] ?? $port;
379 // A URL (DB_URL, REDIS_URL, Meilisearch's host) wins; only its host and port are kept
380 $url = !empty($c['url']) && is_string($c['url']) ? $c['url'] : (is_string($host) && str_contains($host, '://') ? $host : null);
381 if ($url) { $u = parse_url($url) ?: []; $host = $u['host'] ?? ''; $port = $u['port'] ?? $port; }
382 $host = trim((string) $host, '[]');
383 if (!in_array($host, ['127.0.0.1', 'localhost', '::1'], true) || !is_numeric($port)) return;
384 $out[] = ['use' => $use, 'driver' => $driver, 'port' => (int) $port];
385};
386$redis = fn ($use, $name) => $add($use, 'redis', config('database.redis.' . ($name ?: 'default')), 6379);
387$db = config('database.connections.' . config('database.default'));
388$add('database', $db['driver'] ?? null, $db, ['mysql' => 3306, 'mariadb' => 3306, 'pgsql' => 5432, 'mongodb' => 27017][$db['driver'] ?? ''] ?? null);
389$cache = config('cache.stores.' . config('cache.default'));
390if (($cache['driver'] ?? '') === 'redis') $redis('cache', $cache['connection'] ?? null);
391$queue = config('queue.connections.' . config('queue.default'));
392if (($queue['driver'] ?? '') === 'redis') $redis('queue', $queue['connection'] ?? null);
393if (config('session.driver') === 'redis') $redis('session', config('session.connection'));
394$cast = config('broadcasting.connections.' . config('broadcasting.default'));
395if (($cast['driver'] ?? '') === 'reverb') $add('broadcasting', 'reverb', $cast['options'] ?? null, 8080);
396if (config('scout.driver') === 'meilisearch') $add('scout', 'meilisearch', config('scout.meilisearch'), 7700);
397if (config('scout.driver') === 'typesense') $add('scout', 'typesense', config('scout.typesense.client-settings.nodes.0'), 8108);
398echo json_encode(['connections' => $out]);
399`
400}
401
402export type AppConnection = { use: string; driver: string; port: number }
403
404/** Read connectionsPhp's JSON; undefined when there's none. */
405export function parseConnections(stdout: string): AppConnection[] | undefined {
406 const list = phpJson(stdout, '{"connections"')?.connections
407 if (!Array.isArray(list)) return undefined
408 return list.flatMap((c: Record<string, unknown>) =>
409 typeof c?.use === 'string' && typeof c.driver === 'string' && Number.isInteger(c.port) ? [{ use: c.use, driver: c.driver, port: c.port as number }] : [],
410 )
411}
412
413/** The Herd service types that can serve each driver. */
414const DRIVER_SERVICES: Record<string, string[]> = {
415 mysql: ['mysql', 'mariadb'],
416 mariadb: ['mariadb', 'mysql'],
417 pgsql: ['postgresql'],
418 mongodb: ['mongodb'],
419 redis: ['redis'],
420 reverb: ['reverb'],
421 meilisearch: ['meilisearch'],
422 typesense: ['typesense'],
423}
424
425/**
426 * The Herd services the app connects to: one of the right type on the port the app's config names, with what
427 * uses it, e.g. `{ service: Redis…, driver: 'redis', uses: ['cache', 'queue'] }`. A server on a port no Herd
428 * service of that type has is someone else's (Docker, DBngin) and is left out.
429 */
430export function herdNeeds<T extends HerdService>(connections: AppConnection[], installed: T[]): { service: T; driver: string; uses: string[] }[] {
431 const out = new Map<T, { service: T; driver: string; uses: string[] }>()
432 for (const c of connections) {
433 const service = installed.find(s => s.port === c.port && DRIVER_SERVICES[c.driver]?.includes(s.type))
434 if (!service) continue
435 const entry = out.get(service) ?? { service, driver: c.driver, uses: [] }
436 if (!entry.uses.includes(c.use)) entry.uses.push(c.use)
437 out.set(service, entry)
438 }
439 return [...out.values()]
440}
441
442// ─── Routes: the Routes tab's list, where each route's code is, and its page ──
443
444/**
445 * A route as the Routes tab shows it: route:list's columns, where its code is (the controller method, closure or
446 * view; relative to the project when inside it), and the parameters its URL needs a value for (required, with no
447 * default). `id` is its place in the router, for openPagePhp. No domain: a route on its own domain only says so,
448 * and its host stays in PHP.
449 */
450export type TabRoute = {
451 id: number
452 method: string
453 uri: string
454 name: string | null
455 action: string
456 file?: string
457 line?: number
458 params: string[]
459 onDomain: boolean
460}
461
462/** `GET|HEAD` → `GET`; a route that takes every method (Route::any) → `ANY`; a HEAD-only route stays `HEAD`. */
463export function methodLabel(method: string): string {
464 const list = method.split('|').filter(m => m !== 'HEAD')
465 if (!list.length) return method
466 return ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'].every(m => list.includes(m)) ? 'ANY' : list.join('|')
467}
468
469/**
470 * PHP run with `php -r` from the project root: boots the app and prints its routes sorted by URI as route:list
471 * does, each with where its code is and the parameters its URL needs a value for (route and URL::defaults fill
472 * the rest), and the editor Laravel's own `app.editor` setting names (as its error page uses it). A route's
473 * domain doesn't leave PHP: only whether it has one.
474 */
475export function routesPhp(): string {
476 return `
477require 'vendor/autoload.php';
478$app = require 'bootstrap/app.php';
479$app->make(Illuminate\\Contracts\\Console\\Kernel::class)->bootstrap();
480$where = function ($route) {
481 $controller = $route->getAction('controller');
482 if (!is_string($controller)) {
483 // A closure; one from the route cache is serialized and has no file to show
484 $uses = $route->getAction('uses');
485 if (!$uses instanceof Closure) return null;
486 $f = new ReflectionFunction($uses);
487 return [$f->getFileName(), $f->getStartLine()];
488 }
489 [$class, $method] = array_pad(explode('@', ltrim($controller, '\\\\'), 2), 2, '__invoke');
490 if ($class === Illuminate\\Routing\\ViewController::class) return [view()->getFinder()->find((string) ($route->defaults['view'] ?? '')), 1];
491 // Route::redirect has no code of its own to show
492 if ($class === Illuminate\\Routing\\RedirectController::class) return null;
493 $c = new ReflectionClass($class);
494 $m = $c->hasMethod($method) ? $c->getMethod($method) : null;
495 // A method the class inherits (a Livewire page's __invoke) shows the class itself
496 return $m && $m->getDeclaringClass()->getName() === $c->getName() ? [$m->getFileName(), $m->getStartLine()] : [$c->getFileName(), $c->getStartLine()];
497};
498$base = base_path() . DIRECTORY_SEPARATOR;
499$urlDefaults = url()->getDefaultParameters();
500$out = [];
501foreach (array_values(app('router')->getRoutes()->getRoutes()) as $id => $route) {
502 try { $at = $where($route); } catch (Throwable $e) { $at = null; }
503 $file = $at && is_string($at[0]) ? (str_starts_with($at[0], $base) ? substr($at[0], strlen($base)) : $at[0]) : null;
504 preg_match_all('/\\{(\\w+)\\}/', $route->getDomain() . '/' . $route->uri(), $params);
505 $out[] = [
506 'id' => $id, 'method' => implode('|', $route->methods()), 'uri' => $route->uri(), 'name' => $route->getName(),
507 'action' => preg_replace('/@__invoke$/', '', ltrim($route->getActionName(), '\\\\')),
508 'file' => $file, 'line' => $file ? (int) $at[1] : null, 'onDomain' => $route->getDomain() !== null,
509 'params' => array_values(array_diff($params[1], array_keys($route->defaults + $urlDefaults))),
510 ];
511}
512usort($out, fn ($a, $b) => strcmp($a['uri'], $b['uri']));
513// A custom href can name a remote host, so only an editor's name is kept
514$editor = config('app.editor');
515if (is_array($editor)) $editor = $editor['name'] ?? null;
516echo json_encode(['routes' => $out, 'editor' => is_string($editor) && preg_match('/^[\\w-]+$/', $editor) ? $editor : null]);
517`
518}
519
520/** Read routesPhp's JSON, dropping odd entries; undefined when there's none. */
521export function parseRoutes(stdout: string): { routes: TabRoute[]; editor: string } | undefined {
522 const parsed = phpJson(stdout, '{"routes"')
523 if (!parsed || !Array.isArray(parsed.routes)) return undefined
524 const routes = (parsed.routes as Record<string, unknown>[]).flatMap((r): TabRoute[] => {
525 const { id, method, uri, name, action, file, line, params } = r ?? {}
526 if (!Number.isInteger(id) || typeof method !== 'string' || typeof uri !== 'string' || typeof action !== 'string') return []
527 const hasFile = typeof file === 'string' && file !== '' && !/[\0-\x1f]/.test(file) && Number.isInteger(line) && (line as number) >= 1
528 return [{
529 id: id as number,
530 method,
531 uri,
532 name: typeof name === 'string' ? name : null,
533 action,
534 ...(hasFile ? { file: file as string, line: line as number } : {}),
535 params: Array.isArray(params) ? params.filter((p): p is string => typeof p === 'string' && /^\w+$/.test(p)) : [],
536 onDomain: r.onDomain === true,
537 }]
538 })
539 return { routes, editor: typeof parsed.editor === 'string' && /^[\w-]+$/.test(parsed.editor) ? parsed.editor : '' }
540}
541
542/**
543 * PHP run as `php -r <this> -- <id> <method> <uri> <values JSON>`: finds the route (checking it is still the one
544 * the tab showed), has Laravel's own URL generator build its URL from APP_URL with the values, and opens it in
545 * the browser, so the URL never leaves PHP. Only http(s) URLs are opened. Prints `{"opened":true}`, or `false`
546 * with a fixed reason.
547 */
548export function openPagePhp(): string {
549 return `
550require 'vendor/autoload.php';
551$app = require 'bootstrap/app.php';
552$app->make(Illuminate\\Contracts\\Console\\Kernel::class)->bootstrap();
553[, $id, $method, $uri, $values] = $argv + ['', '', '', '', '{}'];
554$route = array_values(app('router')->getRoutes()->getRoutes())[(int) $id] ?? null;
555$answer = ['opened' => false, 'why' => 'the routes changed; refresh the Routes tab'];
556if ($route && implode('|', $route->methods()) === $method && $route->uri() === $uri) {
557 try {
558 // The values typed, then the route's own defaults (the generator applies URL::defaults itself)
559 $names = array_flip($route->parameterNames());
560 $url = url()->toRoute($route, array_intersect_key(json_decode($values, true) ?: [], $names) + array_intersect_key($route->defaults, $names), true);
561 if (!preg_match('#^https?://#', $url)) {
562 $answer['why'] = 'APP_URL is not an http(s) URL';
563 } else {
564 // Nothing the opener prints, which could name the URL, comes back
565 $quiet = ['file', '/dev/null', 'w'];
566 $p = proc_open([PHP_OS_FAMILY === 'Darwin' ? 'open' : 'xdg-open', $url], [['file', '/dev/null', 'r'], $quiet, $quiet], $pipes);
567 $answer = is_resource($p) && proc_close($p) === 0 ? ['opened' => true] : ['opened' => false, 'why' => 'the browser could not be opened'];
568 }
569 } catch (Illuminate\\Routing\\Exceptions\\UrlGenerationException $e) {
570 $answer['why'] = 'a value the URL needs is missing';
571 } catch (Throwable $e) {
572 $answer['why'] = 'Laravel could not build the URL';
573 }
574}
575echo json_encode($answer);
576`
577}
578
579/** Read openPagePhp's answer: `''` when opened, else why not. */
580export function pageNotOpened(stdout: string): string {
581 const answer = phpJson(stdout, '{"opened"')
582 if (answer?.opened === true) return ''
583 return typeof answer?.why === 'string' ? answer.why : 'PHP could not build the URL'
584}
585
586/** Editor links by Laravel's names for them (its error page's `app.editor`), `{file}` an absolute path. */
587const EDITOR_HREFS: Record<string, string> = {
588 antigravity: 'antigravity://file{file}:{line}',
589 atom: 'atom://core/open/file?filename={file}&line={line}',
590 cursor: 'cursor://file{file}:{line}',
591 emacs: 'emacs://open?url=file://{file}&line={line}',
592 fleet: 'fleet://open?file={file}&line={line}',
593 idea: 'idea://open?file={file}&line={line}',
594 kiro: 'kiro://file{file}:{line}',
595 macvim: 'mvim://open/?url=file://{file}&line={line}',
596 neovim: 'nvim://open?url=file://{file}&line={line}',
597 netbeans: 'netbeans://open/?f={file}:{line}',
598 nova: 'nova://core/open/file?filename={file}&line={line}',
599 phpstorm: 'phpstorm://open?file={file}&line={line}',
600 sublime: 'subl://open?url=file://{file}&line={line}',
601 textmate: 'txmt://open?url=file://{file}&line={line}',
602 trae: 'trae://file{file}:{line}',
603 vscode: 'vscode://file{file}:{line}',
604 'vscode-insiders': 'vscode-insiders://file{file}:{line}',
605 'vscode-insiders-remote': 'vscode-insiders://vscode-remote{file}:{line}',
606 'vscode-remote': 'vscode://vscode-remote{file}:{line}',
607 vscodium: 'vscodium://file{file}:{line}',
608 windsurf: 'windsurf://file{file}:{line}',
609 xdebug: 'xdebug://{file}@{line}',
610 zed: 'zed://file{file}:{line}',
611}
612
613/** The link that opens `file` (absolute) at `line` in `editor`; an unknown name gets Laravel's `<name>://open?file=…` form. */
614export function editorHref(editor: string, file: string, line: number): string {
615 const href = Object.hasOwn(EDITOR_HREFS, editor) ? EDITOR_HREFS[editor]! : `${editor}://open?file={file}&line={line}`
616 return href.replace('{file}', file.split('/').map(encodeURIComponent).join('/')).replace('{line}', String(line))
617}
618
619/** macOS bundle ids of editors with a terminal, by Laravel's name for the editor. */
620const EDITOR_APPS: Record<string, string> = {
621 'com.microsoft.VSCode': 'vscode',
622 'com.microsoft.VSCodeInsiders': 'vscode-insiders',
623 'com.vscodium': 'vscodium',
624 'com.todesktop.230313mzl4w4u92': 'cursor',
625 'com.exafunction.windsurf': 'windsurf',
626 'dev.zed.Zed': 'zed',
627 'com.jetbrains.PhpStorm': 'phpstorm',
628 'com.jetbrains.intellij': 'idea',
629 'com.sublimetext.4': 'sublime',
630 'com.panic.Nova': 'nova',
631}
632
633/**
634 * The editor whose built-in terminal Claude Code runs in: by the app's bundle id (`__CFBundleIdentifier`), else
635 * `TERMINAL_EMULATOR` (JetBrains) or `TERM_PROGRAM` (VS Code and its forks say `vscode`); '' in any other terminal.
636 */
637export function terminalEditor(bundle?: string, program?: string, emulator?: string): string {
638 if (bundle && Object.hasOwn(EDITOR_APPS, bundle)) return EDITOR_APPS[bundle]!
639 if (emulator?.startsWith('JetBrains')) return 'phpstorm'
640 if (program === 'vscode' || program === 'zed') return program
641 return ''
642}
643
644/**
645 * The first row of the Routes tab's window, so the selected row `at` has a row drawn on each side (unless at an
646 * end of the list, or the window is under 3 rows): the next arrow press always lands on a drawn row, and the
647 * window follows it.
648 */
649export function routeWindow(at: number, top: number, rows: number, count: number): number {
650 const margin = rows >= 3 ? 1 : 0
651 let next = Math.min(top, at - margin)
652 next = Math.max(next, at - rows + 1 + margin)
653 return Math.max(0, Math.min(next, count - rows))
654}
655
656// ─── Redaction: what goes to Claude ────────────────────────────────────────
657
658/** Key names whose value is a secret: `password=…`, `"api_key": "…"`, `'token' => '…'`, `DB_USERNAME=…`. */
659// Bounded on each side (config keys run long, but not this long), so a long word can't make it backtrack for seconds
660const SECRET_KEY = String.raw`[\w.-]{0,100}(?:passw(?:or)?d|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|app[_-]?key|authorization|credentials?|cookie|signature|dsn|username)[\w.-]{0,100}`
661/**
662 * A secret key and its value, in JSON (also escaped inside a JSON string, `\"password\":\"…\"`), PHP arrays,
663 * query strings, headers and .env lines. A quoted value is taken whole, escaped quotes and spaces included.
664 */
665const KEY_VALUE = new RegExp(
666 String.raw`(\\?["']|)(${SECRET_KEY})\1(\s*(?:=>|[:=])\s*)(?:\\"(?:[^"\\]|\\(?!"))*\\"|"(?:[^"\\]|\\.)*"|'[^']*'|[^\s,;&)}\]]+)`,
667 'gi',
668)
669/**
670 * PHP serialize()d strings under a secret key (queue payloads), raw or escaped in JSON: `s:8:"password";s:7:"hunter2";`.
671 * A serialized string can hold quotes, so the value ends where the next serialized value (or the object) starts.
672 */
673const SERIALIZED = new RegExp(
674 String.raw`(s:\d+:(\\?")(?:[^"\\]|\\u0000){0,100}?${SECRET_KEY}\2;)s:\d+:\2[\s\S]{0,20000}?\2;(?=[sibdaOCN][:;]|\}|$)`,
675 'gi',
676)
677/**
678 * A host key and its value: `Host: db.internal`, `host=…`, `"host":"…"`, `'host' => '…'`, `DB_HOST=…`, also a list
679 * (`'host' => ['10.0.0.5', …]`). The key starts a word, so `localhost:6379` is no key.
680 */
681const HOST_VALUE = new RegExp(
682 String.raw`(?<![\w.-])(\\?["']|)((?:[\w.-]{0,100}[_.-])?host(?:name)?)\1(\s*(?:=>|[:=])\s*)(\[[^\]\n]{0,1000}\]|\\"(?:[^"\\]|\\(?!"))*\\"|"(?:[^"\\]|\\.)*"|'[^']*'|[^\s,;&)}\]]+)`,
683 'gi',
684)
685/** This machine, which Claude may see: it's where Herd's services run, and says nothing about anyone's network. */
686const LOCAL_HOST = /^(?:\\?["'])?\[?(?:localhost|127\.0\.0\.1|::1)\]?(?::\d{1,5})?(?:\\?["'])?$/i
687/** Schemes that always name a server, never a page: their host goes even without a user (`[tcp://10.0.0.5:6379]`). */
688const SERVER_URL = /\b((?:tcp|tls|ssl|redis|rediss|mysql|mariadb|pgsql|postgres(?:ql)?|sqlsrv|mongodb(?:\+srv)?|amqps?|memcached?):\/\/)(?!\[redacted\])(\[[\w.:%-]*\]|[\w.%-]+)/gi
689
690/**
691 * Connection errors that name a host after a phrase, with no key and no URL: `Unable to connect to db.internal:6379`,
692 * `failed to connect to db.internal port 3306`, `Can't connect to MySQL server on 'db.internal' (111)`, `could not
693 * translate host name "db.internal"`, `getaddrinfo failed for db.internal`, `Connection to db.internal:3306 refused`,
694 * `No route to host 10.0.0.5`, Node's `connect ECONNREFUSED 2001:db8::1:3306`. The host is dotted, an IP, or followed
695 * by a port, so `connect to the database` is left alone; the port stays.
696 */
697const CONNECT_HOST = /(\b(?:(?:unable|failed|could not) (?:to )?connect to |could not open socket to |could not translate host name "?|getaddrinfo failed for |established with host "?|connect to MySQL server on ['"]?|connection to |no route to host |\b(?:ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|ENOTFOUND) ))((?:[\w-]+\.)+[\w-]+|\[[\w.:%-]*\]|(?:[\da-f]{0,4}:){2,7}[\da-f]{0,4}|[\w-]+(?=:\d|\s+port\b|['"]\s*\())/gi
698/** An IPv4 or bracketed IPv6 address with a port (`connect ECONNREFUSED 10.1.2.3:5432`): a server, not a version number or a client. */
699const ADDRESS_PORT = /(?<![\w.])(\d{1,3}(?:\.\d{1,3}){3}|\[[\da-f.]*:[\da-f:.]*\])(?=:\d{1,5}\b)/gi
700/** Tokens recognisable by their prefix: AWS key ids, Stripe and Slack keys, GitHub tokens, JWTs. */
701const KNOWN_TOKENS = /\b(?:AKIA[0-9A-Z]{16}|(?:sk|pk|rk)_(?:live|test)_\w{8,}|xox[abeprs]-[\w-]{8,}|gh[pousr]_\w{20,}|github_pat_\w{20,}|eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,})/g
702
703/**
704 * A word after Bearer, Basic or Token that is a credential rather than prose ("CSRF token mismatch", "Basic
705 * authentication failed"): 8+ characters with a digit, base64's + / =, or a lower-case letter later followed by
706 * an upper-case one. Checked in two linear passes, so a long word can't make it backtrack.
707 */
708function looksLikeCredential(value: string): boolean {
709 if (value.length < 8) return false
710 if (/[\d+\/=]/.test(value)) return true
711 const lower = value.search(/[a-z]/)
712 return lower >= 0 && /[A-Z]/.test(value.slice(lower))
713}
714
715/** A stack frame's arguments, `Foo->bar('secret', Array)`, cut to `Foo->bar(...)`. */
716function frameWithoutArgs(line: string): string {
717 return line.replace(/^(\s*#\d+ .*?: [^(]*)\((.+)\)\s*$/, '$1(...)')
718}
719
720/**
721 * Values in an SQL error. Laravel puts the bindings into the SQL unquoted, so they can't be told apart: the
722 * statement is kept up to `where`, `values`, `set` or `having` (table and columns) and the rest cut. In the
723 * driver's own message, quoted strings that aren't identifiers and Postgres' `Key (email)=(…)` are hidden.
724 */
725function sqlWithoutValues(line: string): string {
726 if (!/SQLSTATE|SQL: /.test(line)) return line
727 const at = line.indexOf('SQL: ')
728 const message = (at < 0 ? line : line.slice(0, at))
729 // An opening quote isn't an apostrophe (`Can't connect to MySQL server on 'db.internal'`)
730 .replace(/(?<!\w)'((?:[^'\\]|\\.)*)'/g, (all, inner: string) => (/^[a-z_][a-z0-9_]*(\.[a-z0-9_]+)*$/.test(inner) ? all : "'?'"))
731 .replace(/\)=\([^)]*\)/g, ')=(?)')
732 if (at < 0) return message
733 const sql = line.slice(at)
734 const cut = sql.search(/\s(where|values|set|having)\s/i)
735 return message + (cut < 0 ? sql : sql.slice(0, cut) + sql.slice(cut).replace(/^(\s\w+)[\s\S]*$/, '$1 …)'))
736}
737
738/**
739 * Hide what looks confidential in log entries, exceptions and command output before they go to Claude:
740 * passwords, tokens and keys by name, credentials in URLs, bearer tokens, hosts other than this machine (as a
741 * `host` value, in a server URL, after a connection error's phrase, or an IP with a port; an http(s) URL's hostname stays unless it has a user, but an IP in one goes: `http://[host]:5173/`),
742 * email addresses, SQL values, stack frame arguments, and long random strings. Best effort: it can't know
743 * every secret, so callers still ask before sending log and exception text.
744 */
745export function redact(text: string): string {
746 return text
747 .split('\n')
748 .map(line => {
749 const out = /^\s*#\d+ /.test(line) ? frameWithoutArgs(line) : line
750 return sqlWithoutValues(out)
751 })
752 .join('\n')
753 // The user (and password) in a URL, up to its last @, so a password with an @ (or ; ( ) , … which userinfo
754 // allows) goes too; a quote ends it, so it never runs into the next JSON field or argument
755 // and the host after it (a port stays), unless it's this machine
756 .replace(/(?<![\w+.-])([a-z][\w+.-]*:\/\/)[^\s\/@"'`<>]*(?:@[^\s\/@"'`<>]*)*@(\[[\w.:%-]*\]|[\w.%-]*)/gi, (all, scheme: string, host: string) =>
757 `${scheme}[redacted]@${!host || LOCAL_HOST.test(host) ? host : '[host]'}`)
758 .replace(SERVER_URL, (all, scheme: string, host: string) => (LOCAL_HOST.test(host) ? all : `${scheme}[host]`))
759 // Connection errors name the host: `getaddrinfo for db.internal failed`
760 .replace(/(\bgetaddrinfo for )([\w.:%\[\]-]+)/gi, (all, before: string, host: string) => (LOCAL_HOST.test(host) ? all : `${before}[host]`))
761 .replace(CONNECT_HOST, (all, before: string, host: string) => (LOCAL_HOST.test(host) ? all : `${before}[host]`))
762 .replace(ADDRESS_PORT, (all, address: string) => (LOCAL_HOST.test(address) ? all : '[host]'))
763 .replace(/\b(bearer|basic|token)(\s+)([\w~+\/.=-]{8,})/gi, (all, scheme: string, space: string, value: string) => {
764 const credential = value.replace(/\.+$/, '')
765 return looksLikeCredential(credential) ? `${scheme}${space}[redacted]${value.slice(credential.length)}` : all
766 })
767 .replace(/\bbase64:[A-Za-z0-9+\/]{16,}={0,2}/g, 'base64:[redacted]')
768 // Database login errors name the user: `for user 'forge'@'10.0.0.5'`, `for user "app"`, Postgres' `user "forge"`, `role "forge"`
769 .replace(/(\b(?:for )?(?:user|role)\s+)(["'])[^"']*\2(@(["'])[^"']*\4)?/gi, "$1$2?$2")
770 // and so do Postgres' `server at "db.internal" (10.0.0.5)` and pg_hba.conf rejection `no pg_hba.conf entry for host "10.0.0.5"`
771 .replace(/(\b(?:host|server at)\s+)"([^"\n]*)"( \([\da-f.:]{0,45}\))?/gi, (all, before: string, host: string, address?: string) =>
772 LOCAL_HOST.test(host) || host.startsWith('[host]') ? all : `${before}"[host]"${address ? ' ([host])' : ''}`)
773 .replace(SERIALIZED, '$1s:10:$2[redacted]$2;')
774 .replace(KEY_VALUE, '$1$2$1$3[redacted]')
775 .replace(HOST_VALUE, (all, quote: string, key: string, separator: string, value: string) =>
776 LOCAL_HOST.test(value) ? all : `${quote}${key}${quote}${separator}[host]`)
777 .replace(KNOWN_TOKENS, '[redacted]')
778 .replace(/(?<![\w.+-])[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g, '[email]')
779 .replace(/(?<![\w\/\\.-])(?=[\w+=-]*[A-Z])(?=[\w+=-]*[a-z])(?=[\w+=-]*\d)[\w+=-]{32,}/g, '[redacted]')
780 .replace(/\b[a-f0-9]{40,}\b/g, '[redacted]')
781}
782
783/** about's keys whose value is shown whatever it is: not addresses, and what Claude asks about. */
784const ABOUT_SHOWN = /^(application_name|environment|timezone|locale)$/
785
786/**
787 * `artisan about --json` for the conversation, as `Section` / ` key: value` lines, from an allow-list rather
788 * than by spotting addresses: a value is shown when it's true/false, a number, a version (`12.33.0`, `12.x-dev`),
789 * a driver-like word (`mysql`, `redis`, `ENABLED`), or under ABOUT_SHOWN. Anything else (the app's URL, a host,
790 * an IP, a path, a package's own setting) is `[not shown]`. Undefined when the output isn't about's JSON.
791 */
792export function aboutForClaude(stdout: string): string | undefined {
793 const data = phpJson(stdout)
794 if (!data) return undefined
795 const shown = (key: string, value: unknown): string => {
796 if (value === null || typeof value === 'boolean' || typeof value === 'number') return String(value)
797 if (typeof value !== 'string') return Array.isArray(value) ? value.map(v => shown(key, v)).join(', ') : '[not shown]'
798 if (ABOUT_SHOWN.test(key)) return value
799 const version = /^v?\d+(\.\d+)*(\.x)?(-[\w.]+)?$/.test(value) && !/^\d{1,3}(\.\d{1,3}){3}$/.test(value)
800 return version || /^[\w -]{1,40}$/.test(value) ? value : '[not shown]'
801 }
802 const lines: string[] = []
803 for (const [section, entries] of Object.entries(data)) {
804 lines.push(section)
805 if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
806 for (const [key, value] of Object.entries(entries)) lines.push(` ${key}: ${shown(key, value)}`)
807 } else lines.push(` ${shown(section, entries)}`)
808 }
809 return lines.join('\n')
810}
811
812/** A route from `artisan route:list --json`, its domain reduced to whether it has one. */
813export type ListedRoute = { method: string; uri: string; name: string | null; action: string; onDomain: boolean; middleware: string[] }
814
815/**
816 * `artisan route:list --json` without domains: the conversation gets every route and whether it has a domain
817 * of its own, never the domain. Undefined when the output isn't that JSON, e.g. an app with no routes.
818 */
819export function parseRouteList(stdout: string): ListedRoute[] | undefined {
820 const start = stdout.indexOf('[')
821 const end = stdout.lastIndexOf(']')
822 if (start < 0 || end < start) return undefined
823 let list: unknown
824 try {
825 list = JSON.parse(stdout.slice(start, end + 1))
826 } catch {
827 return undefined
828 }
829 if (!Array.isArray(list)) return undefined
830 const text = (v: unknown) => (typeof v === 'string' ? v : '')
831 return list
832 .filter((r): r is Record<string, unknown> => !!r && typeof r === 'object')
833 .map(r => ({
834 method: text(r.method),
835 uri: text(r.uri),
836 name: text(r.name) || null,
837 action: text(r.action),
838 onDomain: !!text(r.domain),
839 middleware: Array.isArray(r.middleware) ? r.middleware.filter((m): m is string => typeof m === 'string') : [],
840 }))
841}
842
843/**
844 * Whether phpunit.xml sends the tests to an in-memory SQLite database, so a run can't reset or fill a real one
845 * (RefreshDatabase). Commented-out lines don't count.
846 */
847export function testsUseMemoryDb(xml: string): boolean {
848 return /<env\s+name="DB_DATABASE"\s+value=":memory:"/.test(xml.replace(/<!--[\s\S]*?-->/g, ''))
849}
850
851// ─── Test runs ─────────────────────────────────────────────────────────────
852
853export type TestFailure = { name: string; detail: string[] }
854export type TestRun = { passed: number; failed: number; skipped: number; failures: TestFailure[] }
855
856/**
857 * A command that runs the test suite: `php artisan test`, `pest` or `phpunit` (bare or in vendor/bin), or
858 * `composer test`. Not `phpunit.xml` or a package named `pestphp/pest`.
859 */
860export function isTestCommand(command: string): boolean {
861 return /\bartisan\s+test(\s|$)|(^|[\s;&|(]|vendor\/bin\/)(pest|phpunit)(\s|$)|\bcomposer\s+(run(-script)?\s+)?test(\s|$)/.test(command)
862}
863
864/**
865 * Whether a test command's runner actually started, for a Bash run that failed without printing a summary: the
866 * command's first word is the runner (after a simple `cd x &&`), or the output shows PHPUnit's or Pest's banner. A
867 * `ls vendor/bin/pest` that exits 1 mentions the runner without running it.
868 */
869export function testRunnerStarted(command: string, output: string): boolean {
870 const first = /^\s*(?:cd\s+\S+\s*&&\s*)?(?:php\s+artisan\s+test|\.?\/?artisan\s+test|(?:php\s+)?(?:\.\/)?(?:vendor\/bin\/)?(?:pest|phpunit))(\s|$)/
871 return first.test(command) || /^\s*(PHPUnit \d+\.\d+|Pest (Testing Framework )?v?\d+\.\d+)/m.test(output)
872}
873
874/**
875 * The result of a test run from its output: `artisan test` and Pest (`Tests: 2 failed, 30 passed`), or
876 * PHPUnit (`OK (32 tests…)`, `Tests: 32, Assertions: 80, Failures: 2.`), with each failure's name and its
877 * message. Undefined when the output has no summary (the run didn't finish, or isn't a test run).
878 */
879export function parseTestRun(output: string): TestRun | undefined {
880 const lines = output.split('\n').map(l => l.replace(/\s+$/, ''))
881 const count = (text: string, word: RegExp) => Number(text.match(new RegExp(String.raw`(\d+)\s+${word.source}`, 'i'))?.[1] ?? 0)
882 let run: TestRun | undefined
883 const collision = [...lines].reverse().find(l => /^\s*Tests:\s+\d+\s+\w/.test(l))
884 const phpunit = [...lines].reverse().find(l => /^\s*Tests:\s+\d+,/.test(l))
885 const ok = [...lines].reverse().find(l => /^OK \(\d+ tests?/.test(l))
886 if (collision) {
887 const skipped = ['skipped', 'incomplete', 'todos?', 'risky'].reduce((n, w) => n + count(collision, new RegExp(w)), 0)
888 run = { passed: count(collision, /passed/), failed: count(collision, /failed/) + count(collision, /errors?/), skipped, failures: [] }
889 } else if (phpunit) {
890 const field = (name: string) => Number(phpunit.match(new RegExp(`${name}: (\\d+)`))?.[1] ?? 0)
891 const failed = field('Failures') + field('Errors')
892 const skipped = field('Skipped') + field('Incomplete')
893 run = { passed: Math.max(0, field('Tests') - failed - skipped), failed, skipped, failures: [] }
894 } else if (ok) {
895 run = { passed: Number(ok.match(/\((\d+)/)![1]), failed: 0, skipped: 0, failures: [] }
896 }
897 if (!run) return undefined
898
899 // Collision/Pest: `FAILED Tests\Feature\OrderTest > it creates an order`. PHPUnit: `1) Tests\…::test_x`, but
900 // only in its failure and error lists, not the numbered lists of skipped, risky or deprecated tests
901 let listing = false
902 const header = (l: string) => l.match(/^\s*(?:FAILED|ERROR)\s+(\S.*)$/)?.[1] ?? (listing ? l.match(/^\d+\) (\S.*)$/)?.[1] : undefined)
903 const end = (l: string) => /^\s*Tests:\s+\d/.test(l) || /^(FAILURES!|ERRORS!|OK\b|There (was|were) \d+)/.test(l)
904 let current: TestFailure | undefined
905 for (const l of lines) {
906 if (/^There (was|were) \d+ /.test(l)) listing = /^There (was|were) \d+ (failure|error)s?:/.test(l)
907 const name = header(l)
908 if (name) {
909 current = { name: name.trim(), detail: [] }
910 run.failures.push(current)
911 } else if (current && end(l)) current = undefined
912 else if (current && current.detail.length < 30 && (l.trim() || current.detail.length)) current.detail.push(l.trim() ? l.replace(/^ {2}/, '') : '')
913 }
914 for (const f of run.failures) while (f.detail.length && !f.detail.at(-1)!.trim()) f.detail.pop()
915 return run
916}
917
918// ─── Claude's edits ────────────────────────────────────────────────────────
919
920export type EditKind = 'php' | 'migration' | 'route' | 'config' | 'test' | 'view' | 'frontend' | 'composer' | 'env' | 'other'
921
922/**
923 * The category of a project-relative path from its name alone, never its contents. First match wins: `.env.example`
924 * is a template, not an env file, and a Blade file is a view before it is PHP.
925 */
926export function editKind(rel: string): EditKind {
927 const name = rel.slice(rel.lastIndexOf('/') + 1)
928 if (name.startsWith('.env') && name !== '.env.example') return 'env'
929 if (/^(composer\.(json|lock)|package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/.test(name)) return 'composer'
930 if (/^database\/migrations\/.+\.php$/.test(rel)) return 'migration'
931 if (/^routes\/.+\.php$/.test(rel)) return 'route'
932 if (/^config\/.+\.php$/.test(rel)) return 'config'
933 if (/^tests\/.+\.php$/.test(rel)) return 'test'
934 if (rel.endsWith('.blade.php') || rel.startsWith('resources/views/')) return 'view'
935 if (rel.endsWith('.php')) return 'php'
936 if (/\.(js|ts|jsx|tsx|vue|css|scss|sass|svelte)$/.test(name) || name.startsWith('vite.config.')) return 'frontend'
937 return 'other'
938}
939
940/** `path` relative to `root` with `.` and `..` resolved first; undefined when it is outside `root` (or is `root`), or either isn't absolute. */
941export function relativeTo(path: string, root: string): string | undefined {
942 const parts = (p: string) => {
943 const out: string[] = []
944 for (const part of p.split('/')) {
945 if (part === '..') out.pop()
946 else if (part && part !== '.') out.push(part)
947 }
948 return out
949 }
950 if (!path.startsWith('/') || !root.startsWith('/')) return undefined
951 const [base, full] = [parts(root), parts(path)]
952 if (full.length <= base.length || base.some((part, i) => full[i] !== part)) return undefined
953 return full.slice(base.length).join('/')
954}
955
956/** Folders at the project root that hold nothing Claude writes by hand (dependencies, caches, logs): edits there aren't tracked. */
957export function isIgnoredRel(rel: string): boolean {
958 return /^(vendor|node_modules|storage)\//.test(rel) || rel.startsWith('bootstrap/cache/')
959}
960
961/** Whether a test run's label (or command) names part of the suite, so a pass says nothing about the rest. */
962export function testScope(label: string): 'suite' | 'subset' {
963 return /--(exclude-)?(filter|group|testsuite)|--dirty|--covers|--uses|\btests\/\S|\w+Test(\.php)?\b/.test(label) ? 'subset' : 'suite'
964}
965
966export type LintResult = { verdict: 'passed' | 'failed' | 'unknown'; line?: number }
967
968/**
969 * What `php -l` said, as a verdict and the line of the error: exit 0 passed; a parse or fatal error failed (a
970 * compile-time fatal such as a redeclared class counts: the file won't load); anything else (the file wasn't
971 * found, php couldn't run) unknown. Never returns the message, which can quote the file's own text.
972 */
973export function lintResult(exitCode: number, output: string): LintResult {
974 if (exitCode === 0) return { verdict: 'passed' }
975 if (!/(Parse|Fatal) error/.test(output)) return { verdict: 'unknown' }
976 // The last one: the message ends with `in <file> on line N`, and a quoted token could hold the words before it
977 const line = Number([...output.matchAll(/on line (\d+)/g)].at(-1)?.[1])
978 return line > 0 ? { verdict: 'failed', line } : { verdict: 'failed' }
979}
980
981/** A log entry's own timestamp when it looks like one, else undefined: the stamp is whatever sat in the entry's brackets. */
982export function logStamp(time: string | undefined): string | undefined {
983 return time !== undefined && /^\d{4}-\d\d-\d\d[ T][\d:]+$/.test(time) ? time : undefined
984}
985
986/**
987 * The one item the band shows for Claude's edits after a turn: a syntax error, else tests that don't cover them.
988 * Never green; nothing when the tests passed in full, nor when they failed (the failing-tests item says so).
989 * `tests` is undefined when no Tests row applies (views, frontend, env only): nothing to say about tests then.
990 * `words`: what the item says without its mark and file count, for the toast.
991 */
992export function verifyBand(
993 files: number,
994 syntax: string | undefined,
995 tests: { verdict: string; partial?: boolean } | undefined,
996): { text: string; color: 'red' | 'yellow'; words: string } | undefined {
997 if (syntax === 'failed') return { text: '✗ syntax error', color: 'red', words: 'syntax error' }
998 if (!tests || tests.verdict === 'failed') return undefined
999 const n = `${files} file${files === 1 ? '' : 's'}`
1000 const words = tests.verdict === 'passed' ? (tests.partial ? 'only some tests ran' : undefined) : tests.verdict === 'unknown' ? 'tests unclear' : 'tests not run'
1001 return words ? { text: `✎ ${n}: ${words}`, color: 'yellow', words } : undefined
1002}
1003
1004// ─── Herd debug session ────────────────────────────────────────────────────
1005
1006/**
1007 * What Herd's `dumps stop debug` returned, and a count per kind for the Services tab: `12 queries, 3 logs`
1008 * for an object of lists, or by each entry's `type` for a list. Text that isn't JSON is kept as text.
1009 */
1010export function parseDebugCapture(stdout: string): { data: unknown; summary: string } {
1011 const text = stdout.trim()
1012 let data: unknown
1013 try {
1014 data = JSON.parse(text)
1015 } catch {
1016 return { data: text, summary: text ? `${text.length} characters captured` : 'nothing captured' }
1017 }
1018 const counts = new Map<string, number>()
1019 if (Array.isArray(data)) {
1020 for (const entry of data) {
1021 const kind = entry && typeof entry === 'object' && typeof (entry as { type?: unknown }).type === 'string' ? (entry as { type: string }).type : 'entries'
1022 counts.set(kind, (counts.get(kind) ?? 0) + 1)
1023 }
1024 } else if (data && typeof data === 'object') {
1025 for (const [kind, value] of Object.entries(data)) if (Array.isArray(value) && value.length) counts.set(kind, value.length)
1026 }
1027 return { data, summary: [...counts].map(([kind, n]) => `${n} ${kind}`).join(', ') || 'nothing captured' }
1028}
1029
1030/** Keys whose whole value is withheld from Claude in captured data: bound values, headers, cookies, bodies. */
1031const WITHHELD_KEY = new RegExp(String.raw`^(bindings|headers|cookies?|session|payload|body|request_?body|response_?body|${SECRET_KEY})$`, 'i')
1032
1033/** Captured data for Claude: values under WITHHELD_KEY replaced, and every string passed through redact(). */
1034export function redactData(value: unknown, depth = 0): unknown {
1035 if (typeof value === 'string') return redact(value)
1036 if (value === null || typeof value !== 'object') return value
1037 // Too deep to walk: withheld rather than sent as is
1038 if (depth > 20) return '[redacted]'
1039 if (Array.isArray(value)) return value.map(v => redactData(v, depth + 1))
1040 return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, WITHHELD_KEY.test(k) ? '[redacted]' : redactData(v, depth + 1)]))
1041}
1042