npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@webpressvn/agent-runner

v0.16.0

Published

Agent runner: one process per AI employee folder, keeps the folder in sync with Webpress and opens claude -p sessions for tasks.

Readme

agent-runner

One process per AI employee folder. Keeps the folder in sync with Webpress (skills, company rules, CLAUDE.md) and opens claude -p sessions when the task module signals work is ready.

npm install -g @webpressvn/agent-runner

agent-runner init agents/kt-ai-01 --token wpmcp_... --api https://api.example.com --ws wss://ws.example.com
agent-runner sync --dir agents/kt-ai-01
agent-runner next  agents/kt-ai-01
agent-runner serve agents/kt-ai-01

Install

The runner is published to npm, so an AI employee runs on any machine with Node 20 or newer. It does not need the Webpress source tree, and it should not live in it.

npm install -g @webpressvn/agent-runner     # then: agent-runner <command>
npx @webpressvn/agent-runner <command>      # or without installing

serve spawns the Claude Code CLI, so install that too and sign it in on the same machine: npm install -g @anthropic-ai/claude-code, or give the runner process an Anthropic key in its environment (see Secrets).

Working inside the Webpress monorepo instead, every command below reads pnpm agent-runner …, which runs the same code from source through ts-node. The describe manifest prints whichever form applies to the machine it runs on.


What an AI employee is

An AI employee is a normal Webpress user account carrying the «Nhân viên AI» label and a read-write MCP connection. It receives tasks through the task module exactly as a human member does: assigned, commented on, and updated by the same events every other member sees.

What the runner is

The runner is a thin orchestration shell: one Node.js process per agent folder. It:

  1. Keeps the folder in sync: fetches skills (kind 13), company policies, handbooks and regulations (kinds 2, 6, 4), and renders CLAUDE.md from a template.
  2. Connects to the platform WebSocket event bus and wakes a Worker on relevant task events.
  3. The Worker calls task_next via MCP to decide what to do, then opens claude -p inside the agent folder.
  4. Re-runs the sync every day at the times listed in runner.json (the reconcile schedule).

The runner holds no business logic. It never reads task content, never decides statuses, and never writes to the task module except for one case: when a claude -p session ends unexpectedly, the runner pauses the task with a reason that quotes what went wrong. Everything else is the agent's job.


Where an agent folder lives

An agent folder holds the MCP token (.mcp.json). Put it in one of two places:

  • agents/<code>/ inside the repository. The root .gitignore carries agents/, so nothing in it can be committed by an absent-minded git add -A.
  • Anywhere outside the repository (/opt/webpress-agents/<code>, ~/agents/<code>).

Never at the repository root itself. The root already has its own .mcp.json, which belongs to the repository and is tracked in git; an init there would overwrite it with a file holding a live token.


Folder contract

The runner reads and writes exactly these files inside the agent folder (<dir>):

agents/<code>/
  CLAUDE.md                           generated by `sync`; the «## Ghi chú riêng» section is preserved verbatim
  .claude/settings.json               copied once by `init` from a profile template; the guard block is merged in before every session
  .claude/skills/<docCode>/SKILL.md   written by `sync` (one file per platform skill document)
  .mcp.json                           written by `init`: { mcpServers: { webpress: { type:"http", url, headers: { Authorization } } } }
  runner.json                         { apiUrl, wsUrl, mode, services?, timezone, reconcile, session: { maxBudgetUsd, timeoutMinutes, permissionMode, maxResumes, claudePath? }, skills: { kind }, work?: { dirs, mcpConfigs, laneRoot }, browser?: { chrome } }
  manifest.json                       { userId, organizationId, code, name, syncedAt, skills: { "<documentId>": { code, versionId } } }
  notes/                              the agent's own long-term notes (runner never touches this)
  workspace/                          the agent's scratch and output files (runner never touches this)
  runs/<taskCode>-<iso>.json          session result JSON as `claude -p` printed it
  runs/session.lock                   the runner's pid while one of its sessions runs (a standalone `sync` then leaves guard-policy.json alone)
  sessions.json                       { "<taskId>": { sessionId, resumes, updatedAt } }

An assistant folder (mode: "assistant", created with init --assistant) holds three more entries:

assistants/<code>/
  knowledge/raw/<resourceId>.(txt|md|json)   the full text of an article, the subtitles of a video
  knowledge/index.json                       the local index, matched against assistantResource on the platform
  services/docker-compose.yml                written by `agent-runner service up` from the catalogue, never by hand

init creates <dir> itself (parents included) together with notes/, workspace/ and runs/, plus knowledge/raw/, knowledge/index.json and services/ in assistant mode. The runner writes CLAUDE.md, .mcp.json, runner.json, manifest.json and sessions.json. After the first write it changes .claude/settings.json only through the managed guard merge before every session (see «Hàng rào an toàn»), and never touches notes/ or workspace/. knowledge/ belongs to the assistant: the runner writes index.json once, when the folder is created, and otherwise only reads it to back the folder up.

The task code in a run file name is sanitised to [A-Za-z0-9._-], so a code invented on the server can only ever name a file inside runs/.


Commands

| Command | What it does | |---|---| | init <dir> | Create the agent folder and run an initial sync | | sync | Pull skills and rules from Docs, render CLAUDE.md | | next <dir> | Dry run: call task_next once and print the result | | serve <dir> | Run the agent: sync, connect the WebSocket, wake on task events | | describe | Print the MCP-shaped tool manifest for agents | | google login\|service-account\|status\|logout | Connect the AI employee's own Google account (Drive, Docs, Gmail) | | google-mcp --dir <dir> | The local MCP server Claude Code starts from .mcp.json; you never run it by hand | | guard status <dir> | Show the active protection layers, commander list, and forbidden-action count for this agent | | service up\|status\|down --dir <dir> | Assistant mode: write services/docker-compose.yml from the catalogue, then bring the services up, show them, or take them down | | service catalog | Print the service names that have a template in the repository |

Failures look the same everywhere: with --json, one {"ok":false,"error":"..."} object on stdout and exit code 1; without it, one red line on stderr and exit code 1. Never a stack trace (set AGENT_RUNNER_DEBUG=1 to add the stack while debugging). Any text that could carry the MCP token is redacted to token=*** before it is printed.

init

Creates the folder and every file in the folder contract, then runs an initial sync. Before writing anything it validates --api (must be http:// or https://), --ws (must be ws:// or wss://) and every --reconcile time, then calls platform_whoami to verify the token and hris_member_get to read the agent's name and member code. If manifest.json already exists for a different userId the command refuses and exits 1 without touching the folder.

agent-runner init agents/kt-ai-01 \
  --token wpmcp_xxxxxxxxxxxxxxxx \
  --api   https://api.example.com \
  --ws    wss://ws.example.com

# With all flags:
agent-runner init agents/kt-ai-01 \
  --token    wpmcp_xxxxxxxxxxxxxxxx \
  --api      https://api.example.com \
  --ws       wss://ws.example.com \
  --mcp-only \
  --timezone Asia/Ho_Chi_Minh \
  --reconcile 08:00,14:00 \
  --json

Options:

| Flag | Default | Description | |---|---|---| | --token <token> | (required) | MCP bearer token (wpmcp_...) | | --api <url> | (required) | Webpress API base URL (http:// or https://) | | --ws <url> | (required) | Webpress WebSocket URL (ws:// or wss://) | | --mcp-only | (flag) | Limit the agent to Webpress MCP tools: no shell, no git | | --timezone <tz> | Asia/Ho_Chi_Minh | IANA timezone for the reconcile schedule | | --reconcile <times> | 08:00 | Reconcile times HH:mm, comma-separated; the flag may be repeated | | --work-dir <paths...> | (none) | Directories the agent may read and write outside its folder, passed as --add-dir | | --mcp-config <paths...> | (none) | Extra .mcp.json files merged on top of the agent's own | | --lane-root <path> | (none) | Repo root the pnpm lane commands run in; also turns the lane sweep on | | --max-budget <usd> | 0 (no cap) | Dollars one session may spend | | --timeout <minutes> | 0 (no limit) | Minutes one session may run | | --stall-after <minutes> | 15 | Minutes of silence from Claude before the runner reports it | | --chrome | (flag) | Pass --chrome, so the agent can drive the user's Chrome through the extension | | --json | (flag) | Emit result as JSON to stdout |

--work-dir, --mcp-config and --lane-root accept ~, which the runner expands itself: ~ is the shell's, and a path that still carries one is used literally and silently misses.

Nothing stops a session by default: no budget cap, no wall clock. --max-budget-usd reaches claude -p only when maxBudgetUsd is above zero, and timeoutMinutes of 0 arms no timer at all. Both defaults are deliberate: a coding task runs for hours, and a session killed mid-edit leaves the worktree half-written.

Set --timeout 90 or --max-budget 5 on an agent whose work has a predictable size and you get an automatic stop back.

Telling a stuck session from a busy one

With no budget and no wall clock, a wedged claude -p would hold the worker forever: the drain awaits it, and nothing else would notice. That is what --stall-after is for, and it is the reason the runner asks Claude Code for --output-format stream-json --verbose rather than one JSON blob at the end.

The distinction that matters: a runner-side heartbeat proves the runner is alive, not that work is progressing, because a wedged child keeps ticking the same clock. The event stream makes silence evidence instead. A working session emits assistant and tool events continuously, so stallAfterMinutes of nothing at all on either stream means it really is stuck.

What happens then: the runner logs it, and the FIRST warning is also posted as a comment on the task, so it reaches whoever is waiting for the work rather than sitting in a log on a machine nobody watches. Later warnings stay in the log. The session is never killed, which is the whole point of turning the timeout off. Recovery is still a human decision (pm2 restart agent-<code>).

One stall this cannot see: Claude retrying a rate limit is alive and still emitting, so there is no silence to detect. That case has its own signal, rate_limit_event. It is a routine quota heartbeat that arrives on every session, even a three-second one, and its status has three meanings (src/core/rate-limit.ts):

| status | Meaning | What the runner logs | |---|---|---| | allowed | within the limit | nothing | | allowed_warning | still allowed, but a window passed its warning threshold | «hạn mức tuần đã dùng 77%, vượt ngưỡng cảnh báo 75%; Claude vẫn chạy bình thường, hạn mức làm mới lúc …» | | anything else | actually blocked | «Claude bị chặn vì hết hạn mức 5 giờ (…), mở lại lúc …» |

allowed_warning is the one easy to misread. Requests still succeed; resetsAt is when that window rolls over, not when access comes back, because access never went away. The two windows move independently: the 5-hour window can be nearly empty while the weekly one is past its threshold.

The event repeats throughout a session, so each standing is logged once per session and again only when it changes (a warning turning into a block, or the quota falling back within the limit).

The result parsing did not change for any of this: it already took the last JSON line of stdout, and stream-json's last line is the same result object. What did change is that stdout is no longer accumulated whole in memory: a multi-hour event stream is kept as a rolling tail, which is all the run file ever stored.

A --reconcile value that is not a time of day (8h, 24:00, 08:60) is refused by init, not by the first serve.

How much the agent may do. Two modes, chosen by one flag, because an operator should not have to reason about a permission matrix to set up an employee.

  • Full access (the default, no flag): runner.json gets permissionMode: bypassPermissions and .claude/settings.json denies nothing. The agent runs any command on that machine, git push included. This is the default because most agents here are developers.
  • --mcp-only: permissionMode: acceptEdits plus a deny list. The agent can only call Webpress MCP tools, read any file in the folder, and write to workspace/ and notes/. All shell (Bash) access is denied, and so are Write/Edit on .claude/**, CLAUDE.md, runner.json, manifest.json, sessions.json, .mcp.json and runs/**, so the agent cannot raise its own budget, repoint its own MCP server, rewrite its own system prompt or forge its own run log.

Because saying nothing is the open choice, init prints the mode it wrote on the last line. Read it before handing the agent real work.

Changing your mind later means editing permissionMode in runner.json and restarting: init never overwrites an existing .claude/settings.json.

Claude Code trust. init also writes projects["<đường dẫn thư mục>"].hasTrustDialogAccepted: true into ~/.claude.json (or $CLAUDE_CONFIG_DIR/.claude.json). Without it Claude Code prints «Ignoring N permissions.allow entries from .claude/settings.json: this workspace has not been trusted» on stderr and runs the session with no tool permissions at all: the session exits 0, costs real money, asks for permission in its result text, and changes nothing on the platform, so the task looks untouched and the runner's loop breaker stops the drain. A folder created by an older version, or a config init could not write, is fixed by running claude once inside the folder and accepting the trust dialog.

sync

Pulls all skill documents (kind 13), policy documents (kind 2), regulation documents (kind 6), and handbook documents (kind 4) from the platform Docs module. Writes each skill as .claude/skills/<code>/SKILL.md with YAML frontmatter (name, description, both written as quoted strings) so Claude Code lists it as a loadable skill. Renders a new CLAUDE.md from the identity and rules, preserving everything after the ## Ghi chú riêng heading verbatim. Updates manifest.json with the new syncedAt timestamp and skills map.

Before syncing it verifies the token's userId matches manifest.json; a mismatch exits 1.

agent-runner sync --dir agents/kt-ai-01
agent-runner sync --dir agents/kt-ai-01 --json

--dir defaults to the current working directory.

When the Docs tool pack is not deployed yet (docs_document_list answers with the error code mcp.exception.unknownTool), sync does not fail: it prints sync: docs pack chưa có trên máy chủ, bỏ qua kỹ năng và quy định, leaves every skill on disk and in the manifest untouched, renders CLAUDE.md from the identity alone and returns ok: true with fetched=0 removed=0 rules=0. That way serve still starts while the platform branch catches up.

The runner branches on error codes, never on wording. A code reaches the runner on one of two channels (QĐ-74): the error result of a tool call carries it in _meta["webpress.vn/errorCode"] (QĐ-53, mcp-server.md §5), and a refusal when the session opens (bad token, inactive assistant, not a member, module not allowed) is an HTTP 4xx whose JSON-RPC error carries it in error.data.code. The code on the wire is always the full key (mcp.exception.*, assistant.exception.*, task.exception.*). The runner reads only those two fields: mcp.exception.unknownTool (a tool this server or this token does not offer: the Docs pack above, and hris_my_commander_list / hris_my_guard_rule_list on a server without the guard, which then means everyone may command and no forbidden actions) and task.exception.notFound (a task that no longer exists, whose session entry is dropped). An error without the field is an error with no known code, whatever its sentence says. The runner therefore needs a server that sends _meta: publish it after that server is in production.

next

Calls task_next once via MCP and prints the raw result. No session is opened. Use this to check what the platform would hand the agent next, or to verify MCP connectivity.

agent-runner next agents/kt-ai-01
agent-runner next agents/kt-ai-01 --json

When task_next is unavailable (for example the platform branch is not yet deployed) the command prints {"ok":false,"error":"..."} to stdout under --json (one red line on stderr without it) and exits 1 instead of crashing.

serve

Runs the AI employee continuously:

  1. Runs sync (exits 1 if sync fails, so a broken token never silently enters the loop).
  2. Logs sync: fetched=N removed=N.
  3. Wakes the Worker with startup immediately, before the socket is involved: work already in the queue is picked up even when the WebSocket service is unreachable.
  4. Connects the WebSocket and wakes the Worker with reconnect on every open.
  5. Wakes the Worker on every relevant task event:
    • task.assigned, task.status.changed, task.commented: always wake.
    • task.created: wake only when data.assigneeId matches the agent's own userId.
  6. Runs a reconcile sync at the times in runner.json (reconcile, default 08:00), then wakes the Worker with reconcile so anything queued overnight is picked up at once.

Reconcile and reconnect are two different things. Reconcile is the daily sync on the clock; reconnect is the socket coming back after it dropped. Each has its own wake reason of the same name.

What the Worker does on each wake: call task_next; if there is an item, open a claude -p session for it and loop. The loop ends when task_next returns null, when the same task comes back twice in a row (the session changed nothing on the platform, so a second run would spend the same money for the same result) or when shutdown has begun.

When a session fails (timeout, non-zero exit, spawn error, or a result carrying is_error: true), the task is paused with waitType: 4 and the reason Phiên kết thúc sớm: <lý do>: <120 ký tự đầu của kết quả hoặc stderr>, so the human who opens the task sees what happened. The session id is kept for timeout and for is_error (the session is still resumable, and maxResumes caps the loop) and dropped for nonzero_exit and spawn_error, where the stored id is the likeliest cause of the failure.

Every line carries an ISO timestamp while serving (the one-shot commands stay bare), and every line goes through the token redactor on the way out, so no log call can print a wpmcp_ secret by forgetting to. Add --verbose (or AGENT_RUNNER_LOG=debug) for one line per MCP call and the exact claude command line.

Log lines emitted while serving (serve prints them through console.log; the Worker, the session and the socket share the same writer):

| Line | When | |---|---| | serve: <tên> (mã …, userId …) · serve: thư mục … · serve: api … · serve: ws … · serve: đồng bộ lại lúc … | once at startup: which agent, which server, which limits | | sync: fetched=N removed=N | after the startup sync and after each reconcile sync | | sync error: <lý do> | a reconcile sync failed (the process keeps running) | | wake: startup | right after the startup sync | | wake: reconnect | the socket opened (first connect and every reconnect) | | wake: reconcile | a reconcile sync finished | | wake: task.assigned · task.status.changed · task.commented · task.created | a relevant task event arrived | | wake: pending | a wake arrived while a drain was running, and is now running as its own drain | | drain: bắt đầu · drain: xong, N phiên, Ns | each drain, with how much work it did | | task_next: <lý do> <mã> "<tiêu đề>" (trạng thái …) | the platform handed back a task, and why this one | | task_next: không có việc nào cần làm ngay | the queue is empty and the drain ends | | session: bắt đầu <mã> "<tiêu đề>" (plus tiếp phiên cũ, lần n/max when resuming) | a claude -p session opens | | session: xong <mã> lý do=completed lượt=N chi phí=$X thời lượng=Ns | the session finished normally | | session: HỎNG <mã> lý do=timeout\|nonzero_exit\|spawn_error | the session failed; the pause line follows | | task: tạm dừng <mã> waitType=4 lý do="…" | the runner paused the task after a failed session | | run: <đường dẫn> | the session transcript was written under runs/ | | sessions: bỏ N phiên đã xong, còn theo dõi M | the post-drain cleanup dropped confirmed or deleted tasks | | mcp: <tool> LỖI <ms>ms: <lý do> | an MCP call failed, including the ones the caller swallows | | mcp: gọi <tool> (<tên tham số>) · mcp: <tool> xong <ms>ms | --verbose only: one pair per platform call, argument NAMES only, never their values | | claude: <lệnh đầy đủ> (cwd=…, timeout=…m) | --verbose only: the exact command, prompt redacted | | drain: dừng vì task_next trả lại cùng một việc (<taskId>) | the same task twice in a row: the drain stops until the next wake | | drain error: <lý do> | an unexpected error escaped the drain (it never crashes the process) | | task_next failed: <lý do> | task_next is unavailable; the drain ends and waits for the next wake | | task_update_status failed: <lý do> | the pause call failed (swallowed, the drain continues) | | ws: connected | the socket opened | | ws: mất kết nối (<lý do>), thử lại sau N s | one line per reconnect attempt: 3 s, doubling to a 60 s ceiling, reset on the next successful open | | ws: force_disconnect received (<lý do>), stopping | the server kicked this connection and asked it not to reconnect | | ws: bị ngắt kết nối (<lý do>), thoát để pm2 khởi động lại | follows the line above: the process exits with code 2 | | ws: closed | the socket closed after a force_disconnect or an explicit stop | | shutdown: đang chờ phiên hiện tại kết thúc, bấm lần nữa không có tác dụng | a second SIGINT or SIGTERM during shutdown |

Shutdown: send SIGINT (Ctrl-C) or SIGTERM. The first signal tells the Worker to stop, so no further task_next call and no further session can start, closes the WebSocket, clears the reconcile timer and waits for the session that is already running. A second signal logs one line and does nothing: the process exits only once the running session is finished.

Exit codes: 0 clean shutdown · 1 startup failure (identity mismatch, unreachable API, missing folder files) · 2 the server sent force_disconnect with shouldReconnect: false. The runner exits on that instead of staying alive with no socket: pm2 restarts it and the startup sync decides whether the token still works.

agent-runner serve agents/kt-ai-01
agent-runner serve agents/kt-ai-01 --verbose     # thêm một dòng cho mỗi lời gọi MCP

google

Gives the agent its OWN Google account: open a Drive link pasted in a task, edit the file and put the new content back onto the same file, edit a Google Doc in place, send mail from its mailbox. The platform is not involved: the key lives in the agent folder, next to the MCP key, and Webpress never stores or sees it. Design: docs/ai/specs/ai-workforce/google-agent-mcp.md.

# Once, in the company's Google Cloud: enable Drive, Docs and Gmail APIs, create an OAuth client of
# type «Desktop app», download its JSON. Workspace: consent screen «Internal». Gmail: «External» in
# Testing, the agent's address as a test user (Google expires those tokens after 7 days).
agent-runner google login --dir agents/kt-ai-01 --credentials client_secret.json
#   prints a URL: open it in a browser signed in as the AI employee's Google account.
#   No browser on the server: add --port 53682 and `ssh -L 53682:127.0.0.1:53682 <server>` first.

# Google Workspace with many agents: service account + domain-wide delegation, no browser.
agent-runner google service-account --dir agents/kt-ai-01 --key sa.json --as [email protected]

agent-runner google status --dir agents/kt-ai-01    # is the key still good?
agent-runner google logout --dir agents/kt-ai-01    # revoke and delete it

Connecting writes google.json (0600; the service-account key is copied to google-sa.json, 0600), adds a google stdio server to .mcp.json, allows mcp__google__* and denies the agent Read on google.json, google-sa.json and .mcp.json in .claude/settings.json. The next sync adds a «Tài liệu Google và thư» section to CLAUDE.md with the working conventions.

| Tool | What it does | |---|---| | gdrive_search | find files the agent owns or that were shared with it | | gdrive_get | metadata, owners, link, and canEdit / canShare for the agent | | gdrive_download | save into workspace/ and return the local path; Google files export to docx/xlsx/pptx/pdf/md | | gdrive_update_file | upload a workspace/ file ONTO the existing file: same id, same link, old content kept as a revision | | gdrive_create_file | new file in the agent's Drive, optionally converted to a Google Doc/Sheet/Slide | | gdrive_share | share with an email as reader, commenter or writer | | gdocs_read | a Google Doc as markdown | | gdocs_append | append text to a Google Doc, refused if someone edited it in between | | gdocs_replace_text | replace a passage in place, keeping its formatting | | gmail_send | send from the agent's mailbox; taskId required, signature «Nhân viên AI» added, every send appended to runs/gmail-sent.jsonl |

Every local path a tool reads or writes must be inside workspace/; ../google.json is refused. Scopes: drive (a file shared with the agent is invisible under drive.file), documents, gmail.send (send only, cannot read the mailbox).

describe

Prints the MCP-shaped tool manifest for the runner itself. Agents that drive the runner via shell use this to discover the available commands, their input schemas, and the exact invocation templates.

pnpm -s agent-runner describe --json

Always pass --json and run pnpm with -s (silent). Human output goes to stderr; one JSON object goes to stdout. Without -s, pnpm prints its own banner onto stdout and the JSON will not parse.

Result keys: ok, name, version, description, invocation, conventions, folderContract (9 files), tools (4 entries: init, sync, next, serve with inputSchema and returns).


Chế độ trợ lý

runner.json carries mode: "employee" | "assistant". Without it the folder is an AI employee and nothing below applies. An assistant is a personal AI owned by one person: it reads the sources its owner lists, keeps raw text on this machine, writes a wiki and reports on the platform, and answers its owner in conversation. Design: docs/ai/specs/ai-assistant/spec.md; the tool names and shapes the runner relies on: docs/ai/specs/ai-assistant/mcp-contract.md.

Khởi tạo

agent-runner init assistants/tl-01 --assistant --chrome \
  --token wpmcp_... --api https://api.example.com --ws wss://ws.example.com \
  --service whisper qdrant

init --assistant writes mode: "assistant" (and services when given) into runner.json, and creates knowledge/raw/, knowledge/index.json and services/ next to the usual folders. It takes the assistant's code, name and organization from assistant_self_get, never from HRIS: an assistant has no member row (QĐ-37). A disabled or locked assistant stops init before anything is written.

Danh tính và hàng rào

Every sync of an assistant (at startup, on reconnect, at the reconcile times, and on the assistant.source.changed and assistant.config.changed events):

  • reads assistant_self_get and writes it to assistant.json (a protected path). The only person who may give the assistant work is its owner (restricted is always on); it never calls hris_member_get, hris_my_commander_list or hris_my_instruction_get (QĐ-49). The purpose becomes the «Dặn riêng» section of CLAUDE.md. hris_my_guard_rule_list is still called, so the organization's forbidden actions apply to the assistant too;
  • builds allowedHosts of the guard policy from the host of every source with status = 1 (running), replacing any list from guard.json (QĐ-44). Paused, failing and awaiting-owner sources open no host, and a source the assistant creates itself starts awaiting its owner. With no running source the list is empty, which for an assistant means loopback only (QĐ-47). If assistant_source_list fails, the previous list is kept;
  • stops every loop when assistant_self_get answers assistantInactive, prints one line, and waits for assistant.config.changed or the next reconcile time instead of retrying.

Các vòng

All sessions go through one queue, so an assistant never runs two claude sessions at once, and every session takes runs/session.lock like an AI employee's. The two knowledge passes (merging the wiki daily, writing reports at reportCadence) are recurring tasks seeded on the platform (QĐ-38), so they arrive through task_next like any task; the runner adds no scheduler for them. What the runner adds, in priority order:

  1. Quyết định xác nhận (QĐ-95), before conversations and before every task_next; see below.
  2. Hội thoại. assistant.message.created, startup, reconnect, and before every task: the runner calls assistant_thread_pending and opens one session per waiting thread (assistant_run_start { kind: 4 }); the session reads the thread and replies itself.
  3. Task. Before each task session the runner opens a run (kind: 5) to check the budget. Task sessions (the knowledge merge and the report included) run Claude Code's default model unless runner.json names one: "session": { "models": { "task": "sonnet" } }; the source loop uses session.models.source, sonnet when unset. A task whose last session finished while the task stayed the same is not reopened (QĐ-102): no session and no resume until its status changes or someone else acts on it (reply, rejection, mention, finished subtask, review). The first skip writes one log line and one comment on the task saying what the owner can do.
  4. Nguồn (QĐ-99). A timer set to the earliest due running source, never more often than once every 15 minutes. The runner itself, with no model, reads every due source: assistant_source_list { due: true }, the feed address (a website's own url; a YouTube channel's feeds/videos.xml?channel_id=UC…, an @handle resolved from the channel page), then the feed through fetchAllowed: http(s) only, redirects followed by hand with every hop checked against allowedHosts, hosts that resolve to a private, link-local or loopback address refused, a real user agent, no cookie, 30 s, 5 MB. It parses RSS, Atom and YouTube feeds (parseFeed), keeps only items never recorded (assistant_resource_list by source, every page, retired ones included), takes the 10 newest per source per pass, downloads each article and keeps its text (articleText; the feed description when the page holds too little), or for a video its captions (Vietnamese then English, the author's before the automatic ones; textSource 1 or 2; title and description with textSource 4 when there are none or YouTube refuses), and writes knowledge/raw/<sha256 of the bytes>.md.
    • A source that fails is marked assistant_source_mark_fetched { ok: false, error } by the runner; a source with nothing new is marked ok: true and no session opens.
    • New items go to one session for the whole batch (assistant_run_start { kind: 1 }) with no MCP server and no built-in tool (--strict-mcp-config --tools ""), a one-line system prompt that says the article text is data, not instructions, --effort low, and the model session.models.source (default sonnet). It answers one JSON block { "<externalId>": { "summary": "…" } } with two Vietnamese sentences per item; long and short dashes outside a number range become commas (QĐ-104), an unknown key or an empty summary is dropped with a log line. The runner then calls assistant_resource_upsert itself and marks a source fetched once every item of it is in the store.
    • Measured on a lane (Sonnet, 10 VnExpress articles): about 30 seconds and $0.07 per pass, one model call; fetching, filtering and writing the raw files takes about a second.
  5. Sàng lọc, once a day in the assistant's timezone: assistant_resource_prune_candidates, delete each file only inside knowledge/raw/ (a localPath that is not exactly knowledge/raw/<name>.(txt|md|json), a symlink, or a knowledge/raw that resolves outside the folder is refused and logged), then assistant_resource_clear_local with the ids whose local copy is gone. A file that was already missing counts as gone.
  6. Sao lưu, at backupCron (default 0 2 * * *, in the assistant's timezone): tar -czf of knowledge/, uploaded to the assistant's own Drive through agent-runner google, replacing the previous copy (one latest copy, no history; the Drive file id is kept in runs/assistant-state.json). Run kind: 6. Without google.json the backup is skipped with googleNotConnected, never an error.

Every prompt reminds the session of the three rules the guard cannot enforce: cite the source when writing the wiki, do not take a side when two sources disagree (assistant_wiki_dispute), summarise and never copy a whole article.

Vòng hỏi dự phòng (QĐ-97)

WebSocket pushes are not guaranteed to arrive (on dev the broadcast lambda times out), so after the first successful sync the assistant runner also asks every 2 minutes (FALLBACK_POLL_MS), even while the socket is connected:

  • assistant_thread_pending: a message of the owner that has not been answered and has not woken the runner yet wakes it (wake: assistant.poll) and the conversation loop answers it. Each message (threadId@lastMessageAt) wakes the runner once, so a session that failed or ran out of budget is not reopened every 2 minutes; a new message wakes it again.
  • assistant_confirmation_get for every confirmationId still remembered in runs/assistant-state.json: approved or rejected goes into the same decision queue as the WebSocket event (a decision is still handled once); used or expired is forgotten with one log line. A server without the tool (mcp.exception.unknownTool) turns this part off until restart, with one log line.

When nothing waits, a tick is one or two MCP calls and no Claude session. One tick never runs alongside another. An inactive assistant stops the ticks, stop cancels them, and the next successful sync after the assistant is switched on starts them again.

Xác nhận ở mức 2 (QĐ-95)

At autonomy 2 a destructive tool called directly is refused with mcp.exception.confirmationRequired. sync writes a «Mức tự chủ» section into CLAUDE.md from autonomy of assistant_self_get; at level 2 it teaches the sequence: call assistant_confirmation_request { toolName, args, summary }, tell the owner where the work is happening, then stop and wait; one request per call; never assistant_confirmation_execute before the owner agrees. Level 1 only reads, level 3 calls tools directly. The model calls tools through Claude Code's MCP client, which sends only arguments, so the approved call goes through its own tool instead of _meta.

  • Remembering where it was asked. Every conversation and task session reports its tool results to the runner (onToolResult, read from the stream-json output). A successful assistant_confirmation_request is stored as confirmationId -> { threadId | taskId, toolName, summary } in runs/assistant-state.json (the newest 200).
  • assistant.confirmation.decided { confirmationId, status } goes to queueDecision. Status 2 (approved) or 3 (rejected) with an id is queued and wakes the runner; anything else (4 used, 5 expired, no id) is logged and ignored. An id already queued or already settled is ignored too: settled ids are kept in settledConfirmations of the same file, so a resent event after a reconnect or a restart opens no second session.
  • Approved: one session (run kind: 4) calls assistant_confirmation_execute { confirmationId } exactly once and reports the result with assistant_thread_reply or task_comment_create where the request was made. If execute refuses because the request was already used or has expired, the session tells the owner in one sentence and does not ask again; every execute error is also one line in the runner log (xác nhận: execute <id> bị từ chối: …). Rejected: one session reads the owner's note through assistant_confirmation_get, drops the work, writes what was dropped and the note there, and does not ask again unless the owner says so.
  • Nowhere remembered (the state file was lost, or the request came from outside the runner): the session reads summary and note from assistant_confirmation_get and files a task for the assistant itself in «Việc của <tên>», titled «Đã làm việc được duyệt: » or «Người chủ từ chối: ».

Trần ngân sách ngày

assistant_run_start answers budgetLeftUsd; there is always a cap (QĐ-46). When it is <= 0 only conversations keep running (QĐ-40): the source loop waits until midnight in the assistant's timezone and tasks wait for the next day. Checking before a session is not enough on its own, so a task session and a source session run with --max-budget-usd set to the smaller of session.maxBudgetUsd and budgetLeftUsd (QĐ-55): with $0.10 left, the session gets $0.10. A conversation keeps session.maxBudgetUsd. Every skip writes one log line and an assistant_run_finish with status: 4 and error: "budgetExhausted", so the web shows «Hết ngân sách hôm nay» instead of nothing.

Tool MCP runner gọi trực tiếp

Every tool goes through /mcp/assistant. The runner branches on the full error key, read from _meta["webpress.vn/errorCode"] of a tool error or from error.data.code of a refused session (QĐ-53, QĐ-74), never on the wording of the message: an error without either field is an error with no known code. An inactive assistant is refused when the session opens, on every call, with HTTP 403 and mcp.exception.assistantInactive (a tool can also answer assistant.exception.assistantInactive). Either way serve stays up: it prints one line, stops every loop, never exits (so pm2 never restarts it), and tries again only at the reconcile times or on assistant.config.changed, printing nothing more while the assistant is still off.

| Tool | Khi nào | |---|---| | assistant_self_get | every sync, and in init | | assistant_source_list | every sync (status: 1, for allowedHosts), and in the source loop (due: true, then status: 1 to set the next timer) | | assistant_run_start | before every conversation, task, source batch and backup | | assistant_run_finish | after each of those, with the cost or the skip reason | | assistant_thread_pending | before every task and after the task queue is empty | | assistant_resource_prune_candidates | once a day | | assistant_resource_clear_local | after the daily prune, with the ids whose local copy is gone |

The session itself calls the other tools of the contract (assistant_resource_upsert, assistant_source_mark_fetched, assistant_thread_get, assistant_thread_reply, the wiki and report tools).

Danh mục dịch vụ docker

An assistant never calls docker itself (the guard blocks it, rule docker-service). It declares what it needs by catalogue name:

{ "mode": "assistant", "services": ["whisper", "qdrant"] }
agent-runner service catalog                 # whisper, qdrant
agent-runner service up     --dir <dir>      # writes services/docker-compose.yml, then docker compose up -d
agent-runner service status --dir <dir>      # docker compose ps
agent-runner service down   --dir <dir>      # docker compose down

The runner builds services/docker-compose.yml from templates/services/<name>.yml and refuses a name that has no template instead of guessing one. Every template is checked before the file is written: ports bind on 127.0.0.1 only, the only bind mount allowed is the assistant's own knowledge/ folder (whisper reads audio from it), everything else uses a named docker volume, and privileged, the docker socket, host namespaces, devices, cap_add and security_opt are refused. Adding a service means adding a template in the repository, which someone reviews.

services/docker-compose.yml is written by the runner and is a protected path, so the assistant cannot edit it, the same as guard-policy.json. The assistant reaches each service over 127.0.0.1: whisper on 9321, qdrant on 9333.


Hàng rào an toàn

An AI employee runs unattended with full shell access. The guard layer stops two threats: someone who is not authorised slipping work to the agent (commander filtering), and injected instructions in task content pushing the agent to exfiltrate data or push code to an unintended remote (forbidden-action blocking).

Cấu hình trên web

Log in to Webpress and open Nhân sự › Thành viên, filter to AI employees.

  • «Giới hạn» (on each agent's row, visible with the manage AiAgentConfig permission): choose which members may give the agent work (empty list = everyone may), and add per-agent forbidden actions, one per line.
  • «Việc cấm chung» (button next to the filter, same permission): forbidden actions that apply to every AI employee in the organisation.

The agent receives changes immediately via WebSocket. Existing tasks the agent is already running continue unaffected.

When the commander list is non-empty and a new or review item in task_next comes from someone outside it, the runner comments «Tôi chỉ nhận việc từ: A, B. Nhờ một trong những người này giao lại.», returns the task to its sender, and opens no session. Tasks assigned before this feature shipped carry no creator record and are declined once: re-assign them from a listed person to re-start the agent.

Cấu hình trên máy

Create ~/.config/agent-runner/guard.json to apply limits to every agent on this machine. All keys are optional.

{
  "denyRead": ["~/Documents", "~/Desktop"],
  "secretFiles": ["~/.ssh/id_rsa-coolify"],
  "gitRemotes": ["github.com:webpress-core/*", "github.com/webpress-core/*"],
  "uploadHosts": ["api.webpress.vn"],
  "npmPublish": ["@webpressvn/*"],
  "allowedHosts": [],
  "sandbox": false
}

| Key | Meaning | |---|---| | denyRead | Directories blocked by deny rule and hook. Example: "~/Documents" | | secretFiles | Files blocked from direct reads (deny rule, hook); ssh, git, gh still read them so pushing code works. The baseline already covers ~/.npmrc, ~/.config/gh/hosts.yml, and .mcp.json. Example: "~/.ssh/id_rsa-coolify" | | gitRemotes | Push targets allowed. Empty means every remote in the agent's work directories. Example: "github.com:webpress-core/*" | | uploadHosts | Servers that may receive file uploads (scp, curl -F, rsync, …). Example: "api.webpress.vn" | | npmPublish | Package scopes allowed to publish to npm. Example: "@webpressvn/*" | | allowedHosts | Empty means network is unrestricted. Set a list and guard pre blocks every call that reaches a host outside it (rule allowed-host, item 7 below), and the sandbox limits Bash network traffic to the same list. Host match, *.example.com allowed, case and port ignored; localhost, 127.0.0.1 and [::1] always pass. | | sandbox | Defaults to false (opt-in). Set true to add OS-level isolation, but the sandbox also blocks gh config, Unix sockets (pm2, lanes), non-HTTP network (git over SSH) and npm auth: enable it only for agents that do not need those. With it off, denyRead is enforced by deny rules and the guard pre hook (direct reads blocked; a script the agent writes and runs can still read files); OS-level isolation means running the agent under its own macOS user. |

To override for a single agent, add a guard block to that agent's runner.json. Arrays in the agent block are added to the machine file; allowedHosts and sandbox replace it entirely.

Baseline blocks (always on, cannot be disabled from the web)

Before every session sync builds guard-policy.json (mode 0600) in the agent folder and merges a managed block into .claude/settings.json. The operator's own entries are preserved, except env and disableAllHooks, which every merge removes. Environment for agent sessions is set on the runner process (pm2 ecosystem or shell); env in the agent's .claude/settings.json is removed by the runner.

Which claude runs. At startup serve pins one absolute claude path for the whole process: session.claudePath in runner.json when it is set (it must be absolute; a relative value stops serve with an error), otherwise the first claude on the runner's PATH, kept as found there (a symlink such as ~/.local/bin/claude is not resolved, so Claude Code's own updates keep working). Every session is spawned by that path, the hook hands the same path to the judge, and guard status shows it together with the file it resolves to (it never runs it). A claude planted later in another PATH directory changes nothing until the runner restarts, and the agent cannot plant one: see item 5 below.

guard pre (a PreToolUse hook of type command) runs before every Bash, Read, Edit, Write, Glob, Grep, WebFetch, and mcp__google__*, mcp__claude-in-chrome__*, mcp__chrome-devtools* and mcp__playwright__* call, and before every tool of any MCP server whose name ends in __browser_file_upload, __upload_file, __browser_navigate, __navigate_page or __new_page, so a browser server under another name can neither upload nor navigate unchecked. In assistant mode those navigation tools also go to the judge when the host list lets them through, the same as mcp__claude-in-chrome__navigate; for an AI employee they pass straight through. It blocks:

  1. Reading or editing denied directories and secret files (path appears in the call). The baseline secret-file list already includes ~/.npmrc, ~/.config/gh/hosts.yml and .mcp.json. In its own folder the agent may read everything except .mcp.json, google.json and google-sa.json; in every other agent folder registered on the machine (~/.config/agent-runner/agents.json) .mcp.json, google*.json, guard-policy.json, runs/ and .claude/ are denied. A find, mdfind or locate that searches a folder holding a denied path, combined with any reader in the same call, is denied too. Every path is also resolved the way the kernel resolves it: symlinks are followed before a .. that comes after them (also after a name a command earlier in the call may create), and links made earlier in the same call count, so a symlink alias (ln -s <folder> /tmp/r, then /tmp/r/... or /tmp/r/../...) does not get around these rules. The working folder follows the shell instead: cd moves by spelling, the way bash and zsh compute $PWD, so cd S && cd ../.. && cat .mcp.json is checked in the folder two levels above the work folder even when S is a symlink; only cd -P, env -C, git -C and --git-dir enter the physical folder. Where bash falls back to the physical folder (the spelled one does not exist) the guard does too, and when the call can change files or switch the shell to physical mode (set -P, set -o physical) both folders are checked. cd -, pushd, popd and dirs -c are followed exactly (previous folder and folder stack). When the folder is known only at run time (cd "$(...)", cd "$X", cd - before any cd, pushd +N, more than 32 possible folders) or a path starts with an unexpanded variable, a relative path is denied when one of its names is the last name of a denied or secret entry (.mcp.json, google.json, .npmrc, and id_*, hosts.yml, credentials inside .ssh, gh, .aws), and a write when one is the last name of a protected entry (.claude, CLAUDE.md, runner.json, guard-policy.json, manifest.json, sessions.json); cd "$X" && cat README.md and pnpm test still run, and the reason asks for an absolute path or a cd to a folder written out. Unquoted globs (*, ?, [...]) in arguments and cd targets are expanded against the file system component by component and every match is checked; a glob with no match stays literal. A quoted or backslash escaped glob ("src/**/*.ts", '*.ts', \*) is a literal word, as in the shell, so pnpm exec prettier --check "src/**/*.ts" and find . -name '*.ts' are checked as names and never expanded. Past 200 matches with no .. after the glob, the guard checks the folder the matches come from instead (denied only when a denied or secret path, or for a write a protected path, lies at or under it) and still follows every link among the matches, so ls src/*.ts over 251 files and rm -rf node_modules/* run; past 200 with a .. after the glob, or for a cd target, the call is denied as too complex. A link whose source is only known when the command runs (ln -s "$X" r) may not be used later in the same call: make it in its own call. A path longer than the system limit (lstat fails with ENAMETOOLONG) is denied when a .. follows it or when it is written, and checked by its spelling otherwise. A publish folder is checked both where the kernel opens it and where npm resolves it by spelling, and package.json is read only when it is a regular file of at most 1 MiB. Making a link (ln, link, cp -s, cp -l) whose source is a protected, denied or secret path is denied. These lookups (lstat, readlink, folder listings, package.json reads) count toward the per-call work limit: a call that needs too many is denied as too complex rather than left to run past the hook timeout.
  2. Secret-printing commands: security find-*/dump-keychain, gh auth token.
  3. Pushing code outside gitRemotes: git push, send-pack, subtree push, git remote add/set-url to an unmatched URL, gh gist create, gh repo create, gh repo edit --visibility public, npm publish outside npmPublish.
  4. Uploading to hosts outside uploadHosts: scp, rsync, sftp, nc/ncat, curl -T/-F/-d @…, wget --post-file.
  5. Editing the guard configuration of this agent or of any other registered agent: .claude/**, runs/**, runner.json, guard-policy.json, CLAUDE.md, manifest.json, sessions.json, services/docker-compose.yml in each agent folder, plus ~/.config/agent-runner/**, ~/.curlrc, ~/.wgetrc; and the guard's own programs: the runner package, the node binary and its realpath, the agent-runner command the runner started from and agent-runner in every PATH directory, the pinned claude, the folder of its realpath target (for example ~/.local/share/claude/versions) and claude in every PATH directory.
  6. File uploads through Chrome: file_upload, upload_image of claude-in-chrome, always. Uploads through Playwright and Chrome DevTools (any tool ending in __browser_file_upload, which takes paths, or __upload_file, which takes filePaths) are checked instead of refused, because an AI employee testing an attachment or CV upload end to end needs to send a sample file from the repository: the call is blocked when any path lies in a denied, secret or protected entry, or outside the agent folder and workDirs. Paths are resolved the way the kernel opens them (symlinks, then ..), so a link inside a work folder pointing at ~/.ssh does not get a key out. In assistant mode only files inside the assistant's own knowledge/ and workspace/ may be uploaded. A list the guard cannot read (an entry that is not a string) is blocked; an empty list (cancelling the file chooser) passes.
  7. Reaching a host outside allowedHosts, when that list is not empty (rule allowed-host, blocked outright, never sent to the judge): mcp__claude-in-chrome__navigate, WebFetch, a browser_batch holding either of them, the navigation tools of Playwright and Chrome DevTools under any server name (__browser_navigate, __navigate_page unless its type is back, forward or reload, __new_page, all read from url), and a Bash call that carries a URL (curl, wget, an interpreter one-liner, a command whose name is only known at run time). The host is read with the WHATWG URL parser, so https://[email protected]/ reaches evil.example and https://%65vil.example/ reaches evil.example; a URL whose host cannot be read is blocked instead of guessed. Because a redirect can leave the list after the check, curl -L is blocked and wget needs --max-redirect=0; so is anything that hides the real host (curl --proxy, --resolve, --connect-to, -K, --unix-socket, wget -e, --config, --use-proxy, wget -i). For an AI employee an empty list changes nothing, which is how every AI employee runs today. For an assistant an empty list means loopback only (QĐ-47): an assistant with no running source reaches no host at all.
  8. Assistant mode only (mode: "assistant" in runner.json): docker, docker compose, podman, nerdctl, colima and lima in Bash (rule docker-service). An assistant declares the services it needs in runner.json (services), the runner writes services/docker-compose.yml from the templates in templates/services/, and the assistant reaches each service over 127.0.0.1. One docker run -v /:/host would walk around every other rule on this list, which is why the catalogue exists.
  9. Assistant mode only: getting past automation detection (rule automation-bypass). Four things, each a decision of the project owner, not a setting: reading or submitting a captcha answer (including the solver services); changing the browser fingerprint or the user agent (emulate with userAgent, a script that patches navigator, curl -A, curl -H "User-Agent: …", wget -U); changing the proxy or going through one (curl --proxy/--socks5, wget -e …proxy…, a *_proxy variable, proxychains, torsocks, networksetup); and signing in or signing up on a site (form_input, computer with type/key, or any other typing tool whose call reads as a password or a sign-in). An assistant reads with the owner's own session in the owner's own Chrome: it never signs in for them and never creates an account. Typing a password is allowed only when the same call names a host that is in allowedHosts and the call is not a sign-up; account creation is refused even on a listed host. An AI employee keeps the behaviour it has today: these calls still go to the judge.

When the guard itself fails. If the rule engine throws while checking a call, a call that may send data out is blocked. A browser call that navigates or uploads counts as sending data out by definition, on any server and in every mode: any tool ending in __browser_navigate, __navigate_page (unless its type is back, forward or reload), __new_page, __browser_file_upload or __upload_file. A call that stays on the machine is let through only when its input names no denied, secret or protected entry, by path (including ~ and $HOME spellings) or by file name. Otherwise it is blocked under rule error. A guard bug therefore does not stall ordinary work, and does not open a way to the tokens or the hook configuration either.

What the guard cannot see. These are accepted and not checked: CDPATH (a cd name that CDPATH sends elsewhere); shell options set outside the call, such as setopt chase_links or set -o physical in an rc file (the guard models the default options and only sees set -P or set -o physical written in the call); files changed by a background job of an earlier call between the check and the run; links made by mv, cp -P, cp -a, rsync -l, or by ln with a destination known only at run time, and used in the same call; and Read or Write paths that Claude Code resolves by spelling through a link that already exists (making a link to a guarded path is denied). A recursive reader started from a folder known only at run time (cd "$(...)" && grep -r token ., tar czf /tmp/a.tgz .) is not denied unless the call names a guarded entry, because denying it would also block cd "$(git rev-parse --show-toplevel)" && rg TODO; it belongs with the script gap. An unquoted glob in a folder with more than 10 000 entries is denied as too complex; quote the pattern or name a narrower folder. With allowedHosts in force, one more thing stays out of reach: a redirect that a browser tool or WebFetch follows by itself lands on a host the guard never saw, because they resolve redirects inside the call. The list is checked on the address the call asks for.

guard post (a PostToolUse hook) labels comments by non-commanders with "authorMayInstructYou": false so the model treats them as information, not orders.

For calls that may send data out (WebFetch, gmail_send, gdrive_share, gdrive_create_file, gdrive_update_file, gdocs_append, gdocs_replace_text, Chrome form_input, mcp__claude-in-chrome__navigate, mcp__claude-in-chrome__javascript_tool, mcp__claude-in-chrome__computer (type/key actions), mcp__claude-in-chrome__browser_batch (when any item would be judged, or its actions cannot be read), mcp__claude-in-chrome__shortcuts_execute, WebSearch with a query longer than 256 characters, and Bash commands the filter recognises as network-bound: git push, curl, wget, ssh, scp, rsync, gh api, npm publish, or an interpreter with a URL), guard pre asks a small judge model (claude -p --model haiku, run from an empty directory with no hooks and no MCP). Input: the first 4 000 and last 4 000 characters of the tool input JSON (with a [... N ký tự bị lược ...] marker), the org-wide rules, the agent's own rules. A call whose input exceeds 32 KB is blocked before the model with the reason «Nội dung quá dài để model bảo vệ kiểm». Output: { "decision": "allow" | "deny", "reason": "..." }. If the judge cannot answer within 30 seconds or returns an error, the call is blocked with the reason «không kiểm được, thử lại sau». This adds a few seconds to those calls. curl to localhost is not judged.

gmail_send and gdrive_share to addresses outside the organisation run only when the current task has a child task whose title starts with «Xác nhận», whose assignee appears in that child's task_get users[] with isAi 0 (this is enforced: the server returns users[].isAi from task-mcp-tools.service.ts), in completed or confirmed status, with the exact address in the title or description.

Every block writes one line to runs/guard.jsonl (timestamp, tool, rule code, reason; secret content is never written) and prints guard: chặn … to the runner log.

MCP servers added through work.mcpConfigs in runner.json are not covered by guard pre or guard post. Only use trusted servers there.

Hook commands

agent-runner guard pre    # called by the hook, not directly
agent-runner guard post   # called by the hook, not directly
agent-runner guard status <dir>
agent-runner guard status <dir> --json

guard status prints (or returns as JSON) the active protection layers, every denied, secret and protected path (grouped by folder, the home folder shown as ~; --json adds paths with the full lists), the commander list, the count of forbidden actions, and the claude path pinned in the hook with the file it resolves to (for the native installer, versions/<version> shows the version). It never runs that path, because it comes from the agent's own settings. Use it to confirm the policy took effect after updating.

Updating to 0.15.x

npm install -g @webpressvn/[email protected]
pm2 restart agent-<mã>

The runner merges its managed settings block before every session. Prepare ~/.ssh/known_hosts for any SSH host the agent pushes to before running serve; the agent's own tools cannot write inside ~/.ssh.


pm2 recipe

Use pm2 to keep each agent running across restarts. Give each agent its own named process so you can manage them independently, and give pm2 a kill timeout long enough for a session to finish.

pm2 start "agent-runner serve agents/kt-ai-01" --name agent-kt-ai-01 --kill-timeout 1900000
pm2 logs agent-kt-ai-01
pm2 stop agent-kt-ai-01

Why --kill-timeout: on pm2 stop or pm2 restart, pm2 sends SIGINT first (that is its KILL_SIGNAL, not SIGTERM) and then SIGKILLs the process tree once the kill timeout expires. The default is 1600 ms, which is far too short: the runner waits for the running claude -p session, and that session may take up to session.timeoutMinutes (30 minutes by default). 1900000 ms is 31 minutes, one minute more than the session timeout. Keep the two in step: if you raise timeoutMinutes in runner.json, raise --kill-timeout with it.

Note that pm2 kills the whole process tree by default, so the SIGINT reaches claude too. A session interrupted that way exits non-zero, the runner pauses its task with the reason above, and the work resumes on the next start.

After a force_disconnect the runner exits with code 2. pm2 treats that as a crash and restarts it under its own backoff; the restart runs a startup sync, which is what tells you (in the logs, exit code 1) whether the MCP token was revoked.

To restart all agents after a system reboot:

pm2 save           # after adding agents for the first time
pm2 startup        # install pm2 as a system service

Lanes: one per task, swept when the task is confirmed

A coding agent works on several tasks at once, so each one gets its own lane (pnpm lane: a worktree, a port block, a forked database). The lane is named after the task code, lowercased with the separator dropped: TASK-164 becomes task164.

The agent creates the lane. The runner removes it, which is the only arrangement that works: a session that times out, runs out of budget or crashes never reaches whatever cleanup step the agent was told to run.

Set work.laneRoot (or --lane-root) to turn the sweep on. After every drain and at every reconcile, the runner lists the lanes and removes each one whose name decodes to a task code that is confirmed and assigned to this agent. All three conditions are load-bearing:

  • completed is not enough: a reviewer can send the task back, and the agent needs its lane again.
  • The assignee check is what makes the sweep safe on a machine a person also uses. crm5 decodes to CRM-5, and removing that lane because somebody else's CRM-5 is done would destroy their work.
  • Without laneRoot the runner never touches a lane at all.

lane rm refuses a lane whose worktree has uncommitted changes (it wants --force, which the runner never passes). The sweep logs that refusal and keeps the lane, so work the agent never committed is never thrown away by the cleanup. Such a lane is retried on every drain and has to be dealt with by hand: commit it, or remove it with --force.

Nothing is recorded between runs: the lane list is the state. A lane is therefore still cleaned up after the runner restarts, after a session ran out of resumes, and after a crash.

On an agent machine, change database.nameTemplate in the repo's lane.config.json to {source}_{name}. The default {source}_l{index} numbers lanes per machine, so every machine starts at 1 and names its database <source>_l1. Two agent machines sharing one MySQL server then share one database, and dropOnRemove: true means one machine's lane rm drops the database the other is working in. A task code is unique platform-wide, so {source}_{name} cannot collide. queueNamePrefix appends _l{index} the same way, so give each agent machine its own Redis too.

Secrets

MCP token: written by init into .mcp.json inside the agent folder. It is never stored in manifest.json, runner.json, runs/*.json or any log line: every message the runner prints passes through a redaction step that turns a token into token=***. Keep the agent folder under agents/ (already gitignored) or outs