agelgil/watchman
Composer 安装命令:
composer require 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.
关键字:
README 文档
README
A single Laravel artisan command — an LLM-driven ops & investigation agent that reads, searches and introspects your app from inside a guarded Lua sandbox, then reports back (or opens a PR for review).
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, 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 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 · Usage · Scheduling · Configuration · Security model
Features
- Scheduled health monitor and ad-hoc investigator — runs a default ops mission on a schedule, or hand it any task — see 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. - 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-onlyintrospect()(about,route:list,schedule:list,migrate:status,db:show), and publicfetch(). - 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.mdeach 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
watchmanlog 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 & 13.
Contents
- Requirements
- Installation
- Quickstart
- What one run produces
- The audit log
- Usage
- Limitations & caveats
- Scheduling
- Configuration
- How code mode works
- Extending
- Security model
- Development
- Contributing
- Security
- License
Requirements
-
PHP 8.3, 8.4, or 8.5
-
Laravel 12 or 13 with
laravel/ai(^0.2.6) -
The
luasandboxPHP 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=luasandboxin yourphp.ini) and verify:php -m | grep -i luasandbox # should print "luasandbox"
-
ext-curlis 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 withext-curldoes it pin the connection to the already-validated IP (CURLOPT_RESOLVE), closing the DNS-rebinding (TOCTOU) window between validation and connect.
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
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
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
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).
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
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 introspects 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 & 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
luasandboxextension at runtime (see Requirements). Without it,run_luareturnsError: the luasandbox extension is not installed or enabled.and the agent can do nothing useful — a hard dependency (ext-luasandboxincomposer.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+modelis 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_contentmatches files named*.php— which includes.blade.phpviews (they end in.php). It does not search.js/.ts,.json,.yaml, plain text, or log files; for those, useglob+read_file/tail_file(which read any UTF-8 text file insideread_root, subject to the secrets denylist).introspectruns 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 fullread_fileof a larger file returnsError:— it must be paged withoffset/limitor read withtail_file;tail_filebounds its backward buffer at the cap;search_contentskips oversized files and flags the resulttruncated. fetchis on by default and can reach any publichttp(s)URL (SSRF-guarded). Response bodies are capped atfetch_bytes(1 MB default) and truncated with a visible… [truncated: …]notice beyond that. Settools.fetch => falseif the agent has no business making outbound requests.- Non-determinism. Findings depend on the model and on sampling (temperature
0.2, not0). 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_luacall is bounded to ~10 s CPU and 32 MB memory (configurable). Each run also calls a paid LLM provider — see Cost & runtime. - Built on a pre-1.0 API. Watchman depends on
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
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
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 var | Default | Purpose |
|---|---|---|
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 Enterprise |
The 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 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 thereports/directory, the agent writes the file viawatchman.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_MODEandWATCHMAN_REPORT_BODY_BYTESare ignored and can be removed from your.env, along with theparse_modeandreport_body_byteskeys in a previously published config.
Prompt override. Set prompt_path to point at your own file; the full resolution order is in 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 & 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). A clean default-mission run is typically far fewer — a handful of
run_luacalls (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
modelfor routine health runs; reserve a stronger one for ad-hoc audits you invoke by hand. - Token usage is logged to the
watchmanchannel every run (prompt/completion/cache/reasoning tokens), so you can measure real spend rather than guess.
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.*):
| Function | What 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
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 therun_luatool. 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()returnError: the report could not be sent. - A hard crash of the command is logged to the
watchmanchannel, passed to Laravel'sreport()handler, and the command exits non-zero — sowithoutOverlapping()releases and your scheduler sees the failure.
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
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 toread_root. - Writes (
write_file, the report file,MEMORY.md) are contained tostorage_pathand 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) ishttp(s)-only and blocks loopback, link-local (which covers the cloud-metadata IP169.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 viaCURLOPT_RESOLVE(needsext-curl) to defeat DNS rebinding. - Introspect runs only the fixed read-only artisan allowlist:
about,route:list,schedule:list,migrate:status,db:show. Neverconfig: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/procand 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_pris 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_luacall is bounded to ~10 s CPU and 32 MB memory; result caps bound scan output; byte caps bound single operations —file_bytesfor any one file read andfetch_bytesfor a fetched response body (1 MB each by default; see Configuration). - Secrets — the GitHub token and Telegram bot token — live in PHP config and never reach the model or the logs.
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.)
Contributing
Contributions are welcome — see 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
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 (email 4sam21@gmail.com or use GitHub's private vulnerability reporting). We will acknowledge within a few business days.
License
MIT. © Makiba General Trading PLC.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-06