agelgil/watchman 问题修复 & 功能扩展

解决BUG、新增功能、兼容多环境部署,快速响应你的开发需求

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

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

CI PHP 8.3–8.5 Laravel 12 | 13 PHPStan level 10 Coverage 100% License: MIT

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 onerun_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-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 & 13.

Contents

Requirements

  • PHP 8.3, 8.4, or 8.5

  • Laravel 12 or 13 with 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

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 luasandbox extension at runtime (see 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 & 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 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.

'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_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

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

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 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).
  • 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
  • 点击次数: 2
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-07-06

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固