SLOPSHOPPER

lara-cc

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

panebandguardcommandtoasttool
v0.13.0MITupdated 2026-10-13markszymik/lara-cc
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lara-cc
│ ┃ lara ✕ › fix the failing auth test and add an audit log call │ ┃ s: Services q: Queue l: Log t: Tests r: │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ checks not run yet · not checked: APP_ENV (a ⏺ Update(src/auth.ts) │ ┃ answered), the app's serv ⎿ Added 2 lines, removed 1 line │ ┃ unread), migrations (migr ⏺ Bash(bun test) │ ┃ answered), queue (not rea ⎿ 3 pass, 1 fail │ ┃ following) │ ┃ ○ Queue worker not started [ start ] [ re ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ○ Vite dev not started [ start ] [ re │ ┃ ○ Scheduler not started [ start ] [ re ✻ Worked for 42s · done 4:20 PM │ ┃ [x] restart queue on PHP edits [x] syntax c │ ┃ › /lara │ ┃ Queue worker output ⎿ lara-cc: No `artisan` file in this directory, so this does not l │ ┃ (no output yet) │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · lara
s: Services q: Queue l: Log t: Tests r: Routes c: Comma checks not run yet · not checked: APP_ENV (artisan about has app's servers (config unread), migrations hasn't answered), queue (not read yet), l ○ Queue worker not started [ start ] [ restart ] [ output ○ Vite dev not started [ start ] [ restart ] [ output ○ Scheduler not started [ start ] [ restart ] [ output [x] restart queue on PHP edits [x] syntax check after each Queue worker output (no output yet)
README

lara-cc

CI

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.

Install

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.tsx before installing if you want to see exactly what it does, or run claude plugin validate on a clone to list every call it makes.

What you get

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:

  • A process that crashed, e.g. ✗ vite exited (1), until you open the Services tab. Fix: restart.
  • Jobs waiting with no worker, e.g. ? 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.
  • Pending migrations. Fix: migrate.
  • 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.
  • Cached config, routes or events: edits to them don't take effect until the cache is cleared. Fix: clear runs config:clear, route:clear or event:clear for the caches that exist; not optimize:clear, which would also empty your application cache.
  • Maintenance mode (fix: up), and APP_ENV=production on your machine (read from artisan about, not from .env).
  • A stopped Herd service the app connects to, e.g. 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.

Commands

CommandWhat 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.

What Claude gets

  • 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.

What Claude can do on its own

ExamplesAsks you?
Readsroutes, 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 datalog entries, failed-job exceptions, Boost's log tools, a Herd debug sessionYes, see Privacy
Changes inside the project folderLaravel'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 cachemigrate, 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 themYes
Prints secretsconfig:show, db:showYes, and the output is shown only to you, in full, on the pane's Commands tab
Can destroy data or run any codemigrate: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.

Privacy

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:

  • passwords, tokens, keys, cookies, signatures and usernames by name, in .env lines, headers, query strings, PHP arrays, JSON (also JSON escaped inside a string) and PHP-serialized queue payloads
  • hosts other than this machine (localhost, 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
  • credentials in URLs and DSNs (a password containing @ included), bearer and basic auth, the user in digest auth, base64: keys
  • tokens recognisable by their shape: AWS key ids, Stripe and Slack keys, GitHub tokens, JWTs, and other long random strings
Source 2 files
hooks/register.tsx 3827 lines
1import 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 = path
hooks/parse.ts 1042 lines
1/**
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