PHPackages                             agelgil/watchman - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Logging &amp; Monitoring](/categories/logging)
4. /
5. agelgil/watchman

ActiveLibrary[Logging &amp; Monitoring](/categories/logging)

agelgil/watchman
================

An autonomous, sandboxed code-mode monitoring agent for Laravel, built on laravel/ai. The model writes Lua that runs in a memory- and CPU-limited luasandbox VM and calls a compact, hard-guarded capability library, then reports to Telegram.

v0.5.0(2w ago)031MITPHP ^8.3

Since Jun 26Compare

[ Source](https://github.com/agelgil/watchman)[ Packagist](https://packagist.org/packages/agelgil/watchman)[ Docs](https://github.com/agelgil/watchman)[ RSS](/packages/agelgil-watchman/feed)WikiDiscussions Synced 1w ago

READMEChangelogDependencies (28)Versions (9)Used By (0)

Watchman
========

[](#watchman)

> A single Laravel artisan command — an LLM-driven ops &amp; investigation agent that reads, searches and introspects your app from inside a guarded Lua sandbox, then reports back (or opens a PR for review).

[![CI](https://github.com/agelgil/watchman/actions/workflows/ci.yml/badge.svg)](https://github.com/agelgil/watchman/actions/workflows/ci.yml)[![PHP 8.3–8.5](https://camo.githubusercontent.com/c9e0b1dc13f59edf8a6a8e6a321888bec2a4d95d60c366c6601302c562a6b7b9/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048502d382e332d2d382e352d3737374242343f6c6f676f3d706870266c6f676f436f6c6f723d7768697465)](https://www.php.net/)[![Laravel 12 | 13](https://camo.githubusercontent.com/ef749269b4e188533479a48e8d4c00604c54f02116015dee66908c8d8e80cf43/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c61726176656c2d313225323025374325323031332d4646324432303f6c6f676f3d6c61726176656c266c6f676f436f6c6f723d7768697465)](https://laravel.com)[![PHPStan level 10](https://camo.githubusercontent.com/8cfe13864a9b0c1f6ea2c7580523a784d78d6423a704b6617924e9a1faa843af/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6c6576656c25323031302d323536334542)](https://phpstan.org)[![Coverage 100%](https://camo.githubusercontent.com/f437b24ea1149104fe31d20784b1f2164831d6c7dd129720ae01c19d8db8910f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f636f7665726167652d3130302532352d324541303433)](#development)[![License: MIT](https://camo.githubusercontent.com/fdf2982b9f5d7489dcf44570e714e3a15fce6253e0cc6b5aa61a075aac2ff71b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f772e737667)](LICENSE)

Watchman is a single Laravel artisan command — `php artisan watchman` — that you put on a schedule and forget about. It is an LLM-driven application monitoring agent that investigates your app's health on every run and reports back to Telegram.

It is also a general investigative agent. The scheduled run does its default health mission, but hand it *any* ad-hoc task as an argument — `php artisan watchman "review the last 10 commits for bugs and write a report"` — and the same agent turns loose on whatever you ask: a code review, a feature explainer, a targeted audit. It reads, globs, searches, inspects git history, introspects the app, and reports back (and may open a PR for a contained fix).

It is built on [`laravel/ai`](https://github.com/laravel/ai), and what makes it safe to point at a production codebase is **code mode**: instead of exposing dozens of tools to the model, it exposes exactly **one** — `run_lua`. The model writes a small **Lua 5.1** script that runs inside a memory- and CPU-limited [`luasandbox`](https://www.mediawiki.org/wiki/LuaSandbox) VM and calls a tightly-guarded `watchman.*` API. The model can scan many files, filter, and summarise *inside the VM*, returning only the distilled result — fewer round-trips, and large intermediate data never reaches the context window. The entire security boundary lives in PHP; the Lua just orchestrates guarded calls.

**Jump to:** [Quickstart](#quickstart) · [Usage](#usage) · [Scheduling](#scheduling) · [Configuration](#configuration) · [Security model](#security-model)

Features
--------

[](#features)

- **Scheduled health monitor *and* ad-hoc investigator** — runs a default ops mission on a schedule, or hand it any task — see [Usage](#usage).
- **Code mode — one tool, not dozens.** The model writes Lua 5.1 that runs in a sandboxed VM and calls a guarded `watchman.*` API, so it scans, filters and summarises *inside the VM*. See [How code mode works](#how-code-mode-works).
- **Reads, never wrecks.** Realpath-contained reads, a single writable storage dir, a secrets denylist (`.env*`, key material, `.git`, `.ssh`), and SSRF-hardened web fetch.
- **Built-in tools:** read / glob / grep `.php` (incl. `.blade.php`) / tail / file-size / list-dir, `git_changes()`, `system_metrics()` (a read-only host health snapshot), allowlisted read-only `introspect()` (`about`, `route:list`, `schedule:list`, `migrate:status`, `db:show`), and public `fetch()`.
- **Telegram reporting that never silently fails** — sends the emoji-rich markdown **report** as the chat message (rendered to Telegram-safe HTML, escaped by construction, split at Telegram's message limit). If Telegram rejects the formatting, delivery degrades automatically (HTML → plain text) and the agent is told why, so it self-corrects next run. The model writes the content; you lock the destination in config.
- **Optional GitHub PRs (gated).** For a clear, contained fix it *may* open a PR for human review — PR-only, repo-locked, never merged, capped at 20 files / 1 MB.
- **Persistent memory** — rewrites a bounded `MEMORY.md` each run, fed back into the next run's prompt so it knows what to check first.
- **Per-tool kill switches** — every `watchman.*` function is individually toggleable in config; disabled means unregistered in the VM.
- **Built-in audit log** — a dedicated `watchman` log channel records every Lua script, guarded call, and token count; route it wherever you like.
- **Quality bar:** 100% line coverage, PHPStan level 10, Pint — on PHP 8.3–8.5 and Laravel 12 &amp; 13.

Contents
--------

[](#contents)

- [Requirements](#requirements)
- [Installation](#installation)
- [Quickstart](#quickstart)
- [What one run produces](#what-one-run-produces)
- [The audit log](#the-audit-log)
- [Usage](#usage)
- [Limitations &amp; caveats](#limitations--caveats)
- [Scheduling](#scheduling)
- [Configuration](#configuration)
- [How code mode works](#how-code-mode-works)
- [Extending](#extending)
- [Security model](#security-model)
- [Development](#development)
- [Contributing](#contributing)
- [Security](#security)
- [License](#license)

Requirements
------------

[](#requirements)

- **PHP 8.3, 8.4, or 8.5**
- **Laravel 12 or 13** with [`laravel/ai`](https://github.com/laravel/ai) (`^0.2.6`)
- The **`luasandbox`** PHP extension, built against **Lua 5.1** — this is the sandbox the agent runs in. It needs the Lua 5.1 dev headers present **before** you build it:

    ```
    # Debian/Ubuntu
    sudo apt-get install -y lua5.1 liblua5.1-0-dev
    pecl install luasandbox

    # macOS (Homebrew)
    brew install lua@5.1
    pecl install luasandbox
    ```

    Then enable it (`extension=luasandbox` in your `php.ini`) and verify:

    ```
    php -m | grep -i luasandbox     # should print "luasandbox"
    ```
- `ext-curl` is **suggested, not required**. `fetch()` works without it and still validates every resolved IP — on the initial request and on every redirect hop — against the SSRF blocklist; only with `ext-curl` does it *pin* the connection to the already-validated IP (`CURLOPT_RESOLVE`), closing the DNS-rebinding (TOCTOU) window between validation and connect.

Installation
------------

[](#installation)

```
composer require agelgil/watchman
```

Publish the config, and optionally the system prompt:

```
php artisan vendor:publish --tag=watchman-config
php artisan vendor:publish --tag=watchman-prompts   # optional — to customise the system prompt
```

Quickstart
----------

[](#quickstart)

Watchman needs two things: an LLM provider (via `laravel/ai`) and a Telegram destination to report to.

**1. Point it at a model.** Watchman reads provider credentials from your app's `config/ai.php` (the normal `laravel/ai` way), then you tell it which provider + model to use:

```
# .env — provider credentials are read from config/ai.php as usual, e.g. OpenRouter:
OPENROUTER_API_KEY=sk-...

WATCHMAN_LAB=openrouter
WATCHMAN_MODEL=deepseek/deepseek-chat
```

**2. Give it somewhere to report.** Configure Telegram — with no channel configured, `report()` returns `Error: no reporting channel is configured.` and the run can't deliver:

```
WATCHMAN_TELEGRAM_BOT_TOKEN=123456:ABC...
WATCHMAN_TELEGRAM_RECIPIENTS=987654321
```

**3. Run it.**

```
php artisan watchman
```

That's a full run: it investigates, writes a dated report into its storage sandbox (`storage/watchman/reports/*.md`), updates `MEMORY.md`, and delivers to Telegram — the emoji-rich markdown report as the chat message. (The report file and memory write are part of the agent's standard workflow, not a separate engine step.) Everything else on this page is optional tuning.

What one run produces
---------------------

[](#what-one-run-produces)

Run `php artisan watchman`. You get a live, styled console trace — rendered with Termwind and markdown — of exactly what the agent did: the Lua it submitted, each `watchman.*` call, the result, and end-of-run token usage. (This trace is the `watchman` log channel mirrored to your terminal; the same records persist to the log file, so a scheduled, non-TTY run still leaves a full record — only the animated spinner is TTY-only.) The trace below is abbreviated; a real run also prints a final tool/step tally under the token line.

```
  WATCHMAN  ops agent · deepseek/deepseek-chat
 ↳ run the default ops mission

 ▶ run_lua
   ● glob            pattern=storage/logs/*.log
   ● tail_file       path=storage/logs/laravel-2026-06-26.log  lines=200
   ● git_changes     commits=10
   ● introspect      command=migrate:status
   ✓ completed

 ▶ run_lua
   ● write_file      path=reports/2026-06-26-0900.md  content_length=842
   ● write_file      path=MEMORY.md  content_length=410
   ● report          length=842
   ✓ completed

 ⏺ Needs attention: 2× QueryException (deadlock) on `orders` in the last hour
   and one pending migration. Report delivered to Telegram.
 ─────────────────────────────────────────
 ↑ 4,180 in  ↓ 612 out  · 540 reasoning

```

And a dated markdown report — written into the storage sandbox at `storage/watchman/reports/2026-06-26-0900.md` — with a clear shape:

```
# Watchman Report — 2026-06-26 09:00

## Health Summary
Needs attention. queue=redis, cache=redis, broadcast=reverb. APP_ENV=production, APP_KEY set.

## Notable Changes
- 3 commits since yesterday; `database/migrations/2026_06_25_*_add_index.php` touches a hot table.

## Errors & Anomalies
- `Illuminate\Database\QueryException` ×2 (09:14, 09:41) — deadlock on `orders`.

## Attention Required
- One pending migration (`migrate:status`) — run before the index change lands.
```

Across runs it rewrites `storage/watchman/MEMORY.md` (newest-first, bounded). On the next run that file is injected back into the system prompt as the agent's memory, so it knows what to check first and which issues are still open.

The audit log
-------------

[](#the-audit-log)

Watchman registers a dedicated **`watchman` log channel** on boot. Every run is recorded there with its full trail: each submitted Lua script, every guarded `watchman.*` call and its result, the report delivery, any PRs opened, and end-of-run token usage. Secrets never appear (see [Security model](#security-model)).

If your app does **not** already define `logging.channels.watchman`, the package adds a sensible default for you: driver `daily`, file `storage/logs/watchman.log`, level `debug`, 14-day retention.

To send the trail somewhere else — Slack, Sentry, a stack channel, syslog — just define your own `watchman` channel in `config/logging.php`; the package detects it and leaves it untouched:

```
'channels' => [
    'watchman' => [
        'driver' => 'slack',
        'url' => env('LOG_SLACK_WEBHOOK_URL'),
        'level' => 'warning',
    ],
],
```

Usage
-----

[](#usage)

```
php artisan watchman                                                  # run the default ops mission
php artisan watchman "tail the latest log and summarise the errors"   # run a specific task
php artisan watchman -v                                               # verbose: stream the model's reasoning
                                                                       #   AND show the submitted Lua scripts (first 20 lines each)
```

With **no argument** the agent runs its default ops mission (below). Pass a **task argument** and the same agent investigates whatever you ask and reports back — it is not limited to health checks:

```
php artisan watchman "review the last 10 commits for bugs and write a report"
php artisan watchman "summarise what app/Domain/Billing does and flag anything risky"
php artisan watchman "audit routes/api.php and its controllers for missing authorization"
```

In each case it reads/globs/searches the code, walks `git_changes()`, and `introspect`s the app, then delivers a report (and, for a clear contained fix, may open a PR for review). It only orchestrates sandboxed Lua over the guarded `watchman.*` API.

The default mission (when no task argument is given) tails the newest `storage/logs/*.log` and flags ERROR/CRITICAL entries, reviews `git_changes()`, takes a host health snapshot with `system_metrics()` (flagging high disk %, memory pressure, or sustained load), confirms `migrate:status` is clean, sanity-checks `about` + `db:show` (env, app key, queue/cache/broadcast drivers), and ends with a verdict: **All clear**, **Minor issues**, or **Needs attention**.

Limitations &amp; caveats
-------------------------

[](#limitations--caveats)

Read these before pointing Watchman at production. None are bugs — they are deliberate boundaries.

- **CLI-only.** Watchman is a single artisan command. There is no HTTP endpoint, queue job, dashboard, or Filament panel — you invoke it from the scheduler or by hand.
- **Requires the `luasandbox` extension at runtime** (see [Requirements](#requirements)). Without it, `run_lua` returns `Error: the luasandbox extension is not installed or enabled.` and the agent can do nothing useful — a hard dependency (`ext-luasandbox` in `composer.json`), not a graceful-degradation path.
- **Read-only by design.** The agent reads, searches, introspects, and reports. Its *only* write paths are (a) files inside its own storage sandbox and (b) a GitHub PR for human review. It never runs your tests, never deploys, never runs a state-changing artisan command, and never executes arbitrary shell.
- **One model, no failover.** A single `lab` + `model` is used per run. There is no fallback model, retry-on-provider-error, or secondary provider — if the provider is down, the run fails (and is logged).
- **`search_content` matches files named `*.php`** — which *includes* `.blade.php` views (they end in `.php`). It does **not** search `.js`/`.ts`, `.json`, `.yaml`, plain text, or log files; for those, use `glob` + `read_file`/`tail_file` (which read any UTF-8 text file inside `read_root`, subject to the secrets denylist).
- **`introspect` runs a fixed allowlist only**: `about`, `route:list`, `schedule:list`, `migrate:status`, `db:show`. There is no escape hatch to other commands — by design.
- **Single-file operations are byte-capped** at `file_bytes` (1 MB default). A full `read_file` of a larger file returns `Error:` — it must be paged with `offset`/`limit` or read with `tail_file`; `tail_file` bounds its backward buffer at the cap; `search_content` skips oversized files and flags the result `truncated`.
- **`fetch` is on by default** and can reach any public `http(s)` URL (SSRF-guarded). Response bodies are capped at `fetch_bytes` (1 MB default) and truncated with a visible `… [truncated: …]` notice beyond that. Set `tools.fetch => false` if the agent has no business making outbound requests.
- **Non-determinism.** Findings depend on the model and on sampling (temperature `0.2`, not `0`). Two runs over the same state can word or prioritise things differently. Treat the report as a skilled assistant's notes, not a deterministic linter — and a PR it opens or a "Needs attention" verdict is a *proposal for a human*. Watchman never merges or applies anything.
- **Per-run resource cost.** Each `run_lua` call is bounded to ~10 s CPU and 32 MB memory (configurable). Each run also calls a paid LLM provider — see [Cost &amp; runtime](#cost--runtime).
- **Built on a pre-1.0 API.** Watchman depends on [`laravel/ai`](https://github.com/laravel/ai) `^0.2.6`, which has not reached 1.0 — its API may change in breaking ways between releases, and Watchman tracks it.

Scheduling
----------

[](#scheduling)

Watchman is a plain artisan command, so schedule it the way your app already schedules tasks — in `routes/console.php` on Laravel 11+ (or `App\Console\Kernel::schedule()` on older apps). To run the default ops mission hourly, add it with `withoutOverlapping()` so a slow run never stacks on the previous one:

```
use Illuminate\Support\Facades\Schedule;

Schedule::command('watchman')->hourly()->withoutOverlapping();
```

Want a specific task on its own cadence? Pass it as an argument:

```
Schedule::command('watchman "audit routes/api.php for missing authorization"')->weekly();
```

A failed run logs to the `watchman` channel, hands the exception to Laravel's `report()` handler, and exits non-zero — so `withoutOverlapping()` releases and your monitoring sees the failure. Pair the schedule with `->emailOutputOnFailure(...)` if you want an active heads-up.

Configuration
-------------

[](#configuration)

Everything lives in `config/watchman.php` and every key is env-overridable. **Minimum to get a run:** set `lab` + `model` (and that provider's credentials in `config/ai.php`), and configure Telegram (`bot_token` + `recipients`).

The full set of environment overrides:

Env varDefaultPurpose`WATCHMAN_LAB``openrouter``laravel/ai` provider (credentials in `config/ai.php`)`WATCHMAN_MODEL``deepseek/deepseek-chat`Model id`WATCHMAN_PROMPT_PATH`—Override the system-prompt file`WATCHMAN_READ_ROOT``base_path()`Read-only sandbox root`WATCHMAN_STORAGE_PATH``storage/watchman`The ONLY writable dir`WATCHMAN_LUA_MEMORY``33554432` (32 MB)Memory cap per `run_lua` call`WATCHMAN_LUA_CPU``10.0`CPU seconds per `run_lua` call`WATCHMAN_WEB_TIMEOUT``30`Per-fetch socket hang guard (seconds)`WATCHMAN_GLOB_MATCHES``5000`Max glob matches before truncation`WATCHMAN_SEARCH_FILES``2000`Max files scanned per search`WATCHMAN_SEARCH_MATCHES``500`Max search matches before truncation`WATCHMAN_FILE_BYTES``1048576` (1 MB)Max bytes any single file operation loads (`read_file`/`tail_file`/`search_content`)`WATCHMAN_FETCH_BYTES``1048576` (1 MB)Max response-body bytes `fetch()` returns before truncating with a notice`WATCHMAN_TELEGRAM_BOT_TOKEN`—Telegram bot token`WATCHMAN_TELEGRAM_RECIPIENTS`—Comma-sep `chat_id[:thread_id]` or `@channel``WATCHMAN_TELEGRAM_CHUNK_PACING_MS``300`Pause between messages of a multi-message report`WATCHMAN_GITHUB_TOKEN`—PR tools token (`contents:write` + `pull_requests:write`)`WATCHMAN_GITHUB_REPO`—`owner/repo`; both token + repo required for PR tools`WATCHMAN_GITHUB_API_URL``https://api.github.com`For GitHub EnterpriseThe per-tool `tools.*` toggles and `introspect_allowlist` have **no env var** — edit them in the published config. The annotated blocks below explain the keys that need more than a one-liner.

**Provider + model.** Pick any `laravel/ai` provider via `lab` (a `Laravel\Ai\Enums\Lab` value) and a `model`. Configure that provider's credentials in `config/ai.php` as usual. The 40-step cap and `0.2` temperature are set on the `Watchman` agent — see [Extending](#extending) to change them.

```
'lab'   => env('WATCHMAN_LAB', 'openrouter'),   // openrouter | anthropic | openai | …
'model' => env('WATCHMAN_MODEL', 'deepseek/deepseek-chat'),
```

**Per-tool toggles.** Each `watchman.*` function is exposed to the model only when enabled here. A disabled function simply isn't registered in the Lua VM, so the model can't call it.

```
'tools' => [
    'read_file' => true, 'write_file' => true, 'list_dir' => true,
    'glob' => true, 'search_content' => true, 'tail_file' => true,
    'file_size' => true, 'fetch' => true, 'introspect' => true,
    'system_metrics' => true, 'git_changes' => true, 'report' => true,
    'open_pr' => true, 'list_pr' => true,
],
// Read-only artisan commands introspect() may run:
'introspect_allowlist' => ['about', 'route:list', 'schedule:list', 'migrate:status', 'db:show'],
```

**GitHub PR tools (gated).** `open_pr` / `list_pr` are **auto-disabled unless BOTH `token` and `repo` are set** — even if enabled above. The token needs `contents:write` + `pull_requests:write` scoped to `repo`. The tools are **PR-only**: they never push to the default branch and never merge.

```
'github' => [
    'token'   => env('WATCHMAN_GITHUB_TOKEN'),
    'repo'    => env('WATCHMAN_GITHUB_REPO'),   // e.g. "acme/storefront"
    'api_url' => env('WATCHMAN_GITHUB_API_URL', 'https://api.github.com'),
],
```

**Reporting — Telegram only.** Telegram fires when `bot_token` *and* `recipients` are both set. `report(markdown)` sends the emoji-rich report as the chat message — rendered from the agent's markdown to **Telegram-safe HTML**, with tables, per-level headings, and lists, chunked at 4096 UTF-16 units with link previews off. Two caps bound a runaway report: the input is capped at **64 KB** (the memory guard — beyond that it is truncated at a character boundary with a visible `[truncated N bytes]` marker), and delivery is capped at **20 messages per recipient** (the flood guard — rendered HTML can be several times larger than the input, so the bound is enforced on the actual message count). Either truncation surfaces a `Note:` telling the agent to write a shorter report next run, and successive messages are paced (`WATCHMAN_TELEGRAM_CHUNK_PACING_MS`, default 300 ms) so a multi-message report doesn't trip Telegram's flood limit.

**A report is never silently lost.** The HTML is escaped by construction, and chunking is syntax-aware (a split can never land inside an HTML tag/entity; open tags are closed at the seam and reopened in the next chunk, so code blocks survive a split). If Telegram still rejects the formatting, the rejected chunk is re-sent as **plain text** with no `parse_mode` — which Telegram always accepts. Each degradation comes back in `report()`'s result as a `Note:` telling the agent exactly what Telegram objected to, so it can simplify its formatting on the next run — the report itself has already been delivered. A transient `429`/`5xx`/connection error is retried once with a short, capped wait before being treated as a failure. Configure no channel and `report()` returns `Error: no reporting channel is configured.`; a recipient who received only part of a multi-message report is a **partial success** (surfaced with a do-not-resend warning — the delivered part is already in the chat); only a recipient who receives nothing at all counts as a failure, and if every recipient gets nothing, `report()` returns `Error: the report could not be sent.` (logged to the `watchman` channel). The model chooses the report's **content only** — the destination is locked in config.

```
'reporting' => [
    'telegram' => [
        'bot_token'  => env('WATCHMAN_TELEGRAM_BOT_TOKEN'),
        // Comma-separated "chat_id[:thread_id]" or "@channelusername"
        'recipients' => (string) env('WATCHMAN_TELEGRAM_RECIPIENTS', ''),
        // Pause between successive messages of a multi-message report
        'chunk_pacing_ms' => (int) env('WATCHMAN_TELEGRAM_CHUNK_PACING_MS', 300),
    ],
],
```

> **Where do reports land?** As part of its standard workflow the agent writes a dated report into its **storage sandbox** (`{storage_path}/reports/*.md`) — the package creates the `reports/` directory, the agent writes the file via `watchman.write_file()`. That is just the agent writing to its own storage: a local record, not a delivery channel. **Delivery is Telegram only** — the report becomes the chat message.

> **Upgrading from `report(summary, body)`?** `report()` now takes a single markdown document and no longer attaches a PDF; `WATCHMAN_TELEGRAM_PARSE_MODE` and `WATCHMAN_REPORT_BODY_BYTES` are ignored and can be removed from your `.env`, along with the `parse_mode` and `report_body_bytes` keys in a previously published config.

**Prompt override.** Set `prompt_path` to point at your own file; the full resolution order is in [Extending](#extending).

```
'prompt_path' => env('WATCHMAN_PROMPT_PATH'),
```

**Sandboxes and limits.** `read_root` is the read sandbox; `storage_path` is the write sandbox (writes, reports, and `MEMORY.md` all land there). The result caps bound how much a single scan can return — `glob`/`search_content` stop collecting at the cap and flag the result `truncated` — so a huge tree can never blow up memory or the model's context. The Lua heap itself is capped at `memory_limit` (32 MB default); a script that accumulates many paginated pages into one in-VM table can still hit it and surface a generic `Error: Lua error: ...`, so paginate (`page`/`per_page`) rather than raising `WATCHMAN_LUA_MEMORY`. The byte caps bound single operations the same way: `file_bytes` caps how much any one file operation loads — a full `read_file` refuses a larger file (page it with `offset`/`limit` or use `tail_file`), `tail_file` bounds its backward buffer, and `search_content` skips oversized files (flagging the result `truncated`) — while `fetch_bytes` truncates an over-limit HTTP response body with a visible `… [truncated: …]` notice.

```
'read_root'    => env('WATCHMAN_READ_ROOT', base_path()),                  // read-only root
'storage_path' => env('WATCHMAN_STORAGE_PATH', storage_path('watchman')),  // the ONLY writable dir

'sandbox' => [
    'memory_limit' => (int) env('WATCHMAN_LUA_MEMORY', 32 * 1024 * 1024),  // per run_lua call
    'cpu_limit'    => (float) env('WATCHMAN_LUA_CPU', 10.0),                // seconds, per call
],

'web_timeout' => (int) env('WATCHMAN_WEB_TIMEOUT', 30),                     // per-fetch hang guard, seconds

'limits' => [
    'glob_matches'   => (int) env('WATCHMAN_GLOB_MATCHES', 5000),
    'search_files'   => (int) env('WATCHMAN_SEARCH_FILES', 2000),
    'search_matches' => (int) env('WATCHMAN_SEARCH_MATCHES', 500),
    'file_bytes'     => (int) env('WATCHMAN_FILE_BYTES', 1_048_576),   // per-file cap: read_file / tail_file / search_content
    'fetch_bytes'    => (int) env('WATCHMAN_FETCH_BYTES', 1_048_576),  // fetch() response-body cap
],
```

### Cost &amp; runtime

[](#cost--runtime)

Every run calls your configured LLM provider — a real, recurring cost that adds up on a tight schedule. Budget with these levers:

- **Round-trips per run.** The agent is capped at **40 steps** (see [Extending](#extending)). A clean default-mission run is typically far fewer — a handful of `run_lua` calls (investigate → write report → update memory → send). The step cap is the worst case, not the norm.
- **Tokens stay small by design.** Code mode is the cost control: large intermediate data (whole logs, hundreds of grep hits) is filtered *inside the Lua VM* and never enters the model's context.
- **Cadence is the biggest dial.** `->hourly()` is ~720 runs/month; `->daily()` is ~30. Pick the slowest cadence that still catches what you care about, and always pair it with `->withoutOverlapping()`.
- **Model choice dominates the bill.** Set a cheaper `model` for routine health runs; reserve a stronger one for ad-hoc audits you invoke by hand.
- **Token usage is logged** to the `watchman` channel every run (prompt/completion/cache/reasoning tokens), so you can measure real spend rather than guess.

How code mode works
-------------------

[](#how-code-mode-works)

Most agents hand the model dozens of tools and let it fire them one at a time — a round-trip per file read, per search, per filter, and every intermediate blob (a 50 MB log, 400 grep hits) gets pushed back through the model's context.

Watchman exposes a single `run_lua` tool instead. The model writes a Lua 5.1 script, submits it, and must `return` a string. The script runs inside a fresh `luasandbox` VM whose only outside capability is the guarded `watchman` table — so scanning a thousand files, picking the matches, and summarising them is one round-trip, and the large intermediate data never leaves the sandbox. The model may call `run_lua` several times across a turn (one fresh sandbox per call) — typically once to investigate, once to write the report, once to send it. Inside the VM there is **no `io`, no `os.execute`, no network, and no `require`** — only `watchman.*`. Denied operations return a string starting with `Error:`. Each `watchman.*` function is backed by a plain, typed class in the package's compact `src/Library/*` capability library — but the model only ever sees the single `run_lua` tool.

The API (each function is individually toggleable in `tools.*`):

FunctionWhat it does`read_file(path[, offset, limit])`Read a **UTF-8** text file by relative path (`offset`/`limit` are 1-based line numbers)`tail_file(path[, lines])`Last `lines` lines of a file (default 100)`file_size(path)`Returns `{ path, bytes, human, lines }``list_dir([path, page, per_page])`List a directory (paginated)`glob(pattern[, page, per_page])`Match files with `*`, `?`, `**` (paginated, capped)`search_content(pattern[, path, page, per_page])`Grep `*.php` files (including `.blade.php` views) for a pattern`write_file(path, content[, append])`Write inside the storage sandbox (replaces by default, `append=true` to append)`fetch(url)`Fetch a public `http(s)` URL (SSRF-guarded)`introspect(command)`Run one read-only artisan command from the allowlist`system_metrics()`Host health snapshot — disk, memory, CPU/load, network, uptime, php — read-only, no root, from `/proc` + PHP builtins (degrades gracefully)`git_changes([commits])`Recent commits + changed files via `git log` + `git diff --stat` (default 10)`report(markdown)`Deliver the run report to Telegram as the emoji-rich chat message; content only, destination is locked`open_pr(title, body, files[, base])`Open a GitHub PR proposing a fix (human review only) → returns the PR URL`list_pr([state, limit])`List the repo's pull requests (`state` = `open|closed|all`, default `open`; `limit` 1–50, default 20)Plus helpers, always available: `output(msg)` (a progress note between steps), `json_encode(t)`, `json_decode(s)`. Tables are 1-based — walk them with `ipairs`.

A worked example — tail the newest log file and surface only error lines:

```
local logs = watchman.glob('storage/logs/*.log')
if logs.total == 0 then return 'No log files found.' end

local newest = logs.matches[logs.total]          -- 1-based tables
local tail   = watchman.tail_file(newest.path, 500)

local errors = {}
for line in tail:gmatch('[^\n]+') do
  if line:find('ERROR') or line:find('CRITICAL') then errors[#errors + 1] = line end
end
return #errors .. ' error lines in ' .. newest.path .. '\n' .. table.concat(errors, '\n')
```

### How failures behave

[](#how-failures-behave)

Failures are designed to be *soft* — a denied or broken operation never crashes the run, it becomes a string the model reads and works around.

- **Denied / impossible operations** (blocked path, bad argument type, disabled tool, symlink write, private-IP fetch) return a string starting with `Error:`. The model is instructed to read it, adapt, and **not blindly retry** — it does not throw.
- **Lua timeouts / script errors** come back as `Error: Lua script timed out …` / `Error: Lua error: …` from the `run_lua` tool. The turn continues; the model can submit a simpler script.
- **Partial reports still ship.** If the agent runs out of steps or hits errors mid-run, the prompt directs it to write and send the partial report with what it has — you get a degraded report, not silence.
- **Report delivery is best-effort per recipient.** A recipient that fails (e.g. a bad chat id) is logged but doesn't stop the others; only if *every* recipient fails does `report()` return `Error: the report could not be sent.`
- **A hard crash** of the command is logged to the `watchman` channel, passed to Laravel's `report()` handler, and the command exits non-zero — so `withoutOverlapping()` releases and your scheduler sees the failure.

Extending
---------

[](#extending)

Watchman is built to be subclassed and reconfigured, not forked.

**Change MaxSteps / Temperature / model wiring.** The step cap and temperature are `#[MaxSteps]` / `#[Temperature]` attributes that `laravel/ai` reads off the agent *class*, so override them by subclassing and rebinding in a service provider:

```
use Agelgil\Watchman\Agents\Watchman;
use Laravel\Ai\Attributes\{MaxSteps, Temperature};

#[MaxSteps(20)]
#[Temperature(0.0)]
final class TighterWatchman extends Watchman {}

// In a service provider's register():
$this->app->bind(Watchman::class, TighterWatchman::class);
```

The command resolves the agent from the container (`app(Watchman::class)`), so the bind is all it takes — no need to touch the command.

**Override the system prompt.** Three-tier resolution, first match wins: `config('watchman.prompt_path')` → the published `resources/prompts/watchman.md` (`vendor:publish --tag=watchman-prompts`) → the package's bundled default. The agent substitutes `{date}`, `{memory}`, and `{report_channel}` into whichever file is used, so keep those placeholders if you rewrite it.

**Toggle individual tools.** Flip any `watchman.*` function off in `config('watchman.tools')` and it is never registered in the Lua VM — the model literally cannot call it. Want a strictly read-only agent? Set `write_file`, `report`, and `open_pr` to `false`. Want no outbound HTTP at all? Set `fetch => false`.

Security model
--------------

[](#security-model)

The Lua VM provides isolation and hard resource limits; every actual capability is a PHP closure that validates its arguments before it acts:

- **Reads** (`read_file`, `glob`, `search_content`, `tail_file`, `file_size`, `list_dir`) are realpath-contained to `read_root`.
- **Writes** (`write_file`, the report file, `MEMORY.md`) are contained to `storage_path` and **refuse to write through a symlink** (`Error: refusing to write through the symlink …`).
- A **secrets denylist** hides `.env*`, key material, `.git`, and `.ssh` — checked both on the raw requested path *and* on the canonicalized path after symlink resolution.
- **WebFetch** (`fetch`) is `http(s)`-only and blocks loopback, link-local (which covers the cloud-metadata IP `169.254.169.254`), private/reserved, **CGNAT (100.64.0.0/10)**, and **NAT64 (64:ff9b::/96)** ranges. It **re-validates every redirect hop** and **pins the connection to the validated IP** via `CURLOPT_RESOLVE` (needs `ext-curl`) to defeat DNS rebinding.
- **Introspect** runs only the fixed read-only artisan allowlist: `about`, `route:list`, `schedule:list`, `migrate:status`, `db:show`. Never `config:show`, never a state-changer.
- **GitChanges** runs only fixed read-only git subcommands.
- **SystemMetrics** (`system_metrics`) is read-only and needs no root — it reads host health from `/proc` and PHP builtins, changes nothing, and degrades gracefully when a source is unavailable.
- **`report`**'s destination is locked in config — the model chooses only the content, which is sanitised to Telegram's allowed tag subset by construction, so it can never inject markup.
- **`open_pr`** is PR-only and repo-locked, blocks CI/secret/lockfile paths (`.github/`, `composer.lock`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `Dockerfile`, `docker-compose*`, `deploy/`), and is capped at **20 files / 1 MB**.
- **Resource limits:** each `run_lua` call is bounded to ~10 s CPU and 32 MB memory; result caps bound scan output; byte caps bound single operations — `file_bytes` for any one file read and `fetch_bytes` for a fetched response body (1 MB each by default; see [Configuration](#configuration)).
- **Secrets** — the GitHub token and Telegram bot token — live in PHP config and **never reach the model or the logs**.

Development
-----------

[](#development)

```
composer install
composer format     # Pint
composer stan       # PHPStan level 10
composer test       # Pest (Orchestra Testbench) — does NOT enforce the coverage floor
composer coverage   # pest --coverage --min=100 — the coverage gate (needs a coverage driver + luasandbox)
```

The suite holds **100% line coverage** of every line not explicitly annotated as an unreachable defensive branch (marked with `@codeCoverageIgnore`), and passes **PHPStan level 10** and a Pint preset. `composer test` alone does **not** enforce the coverage floor — `composer coverage` is the gate, and it is what CI runs, with the highest supported dependencies on PHP **8.3, 8.4 and 8.5** plus the lowest supported dependencies (Laravel 12) on PHP 8.3. (Coverage and static analysis bound the package's plumbing; the model's *findings* are inherently non-deterministic — see [Limitations](#limitations--caveats).)

Contributing
------------

[](#contributing)

Contributions are welcome — see **[CONTRIBUTING.md](CONTRIBUTING.md)** for setup (including building `luasandbox`), commit style, and PR expectations. Before opening a PR, run the full gate locally — it must stay green:

```
composer format     # Pint
composer stan       # PHPStan level 10
composer coverage   # Pest, 100% line coverage (needs a coverage driver + luasandbox)
```

CI runs the same checks with the highest supported dependencies on PHP 8.3, 8.4 and 8.5, plus the lowest supported dependencies (Laravel 12) on PHP 8.3. New tools or relaxed guards must ship with tests that keep coverage at 100% and must not weaken the boundary documented in [Security model](#security-model).

Security
--------

[](#security)

Watchman is itself a security boundary, so its own vulnerabilities matter. If you find a sandbox escape, an SSRF or path-traversal bypass, or any way the agent can read or write outside its configured roots, please report it **privately** rather than opening a public issue — see **[SECURITY.md](SECURITY.md)** (email `4sam21@gmail.com` or use GitHub's private vulnerability reporting). We will acknowledge within a few business days.

License
-------

[](#license)

[MIT](LICENSE). © Makiba General Trading PLC.

###  Health Score

41

—

FairBetter than 87% of packages

Maintenance96

Actively maintained with recent releases

Popularity10

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity44

Maturing project, gaining track record

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~5 days

Total

8

Last Release

18d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/8fd28cb32a4cca3aaf21187959977a78066f4bc4c8b110c959efcf117e6dc4c4?d=identicon)[SamAsEnd](/maintainers/SamAsEnd)

---

Tags

laravelmonitoringaiAgenttelegramsandboxoperationsluaagelgilluasandboxcode-mode

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/agelgil-watchman/health.svg)

```
[![Health](https://phpackages.com/badges/agelgil-watchman/health.svg)](https://phpackages.com/packages/agelgil-watchman)
```

###  Alternatives

[laravel/framework

The Laravel Framework.

34.9k556.2M21.5k](/packages/laravel-framework)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

79227.1M246](/packages/laravel-mcp)[psalm/plugin-laravel

Psalm plugin for Laravel

3345.4M354](/packages/psalm-plugin-laravel)[spatie/laravel-health

Monitor the health of a Laravel application

88212.7M190](/packages/spatie-laravel-health)[laravel/ai

The official AI SDK for Laravel.

1.1k4.6M333](/packages/laravel-ai)[aedart/athenaeum

Athenaeum is a mono repository; a collection of various PHP packages

265.2k](/packages/aedart-athenaeum)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
