@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-01Install
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 installingserve 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:
- Keeps the folder in sync: fetches skills (kind 13), company policies, handbooks and
regulations (kinds 2, 6, 4), and renders
CLAUDE.mdfrom a template. - Connects to the platform WebSocket event bus and wakes a Worker on relevant task events.
- The Worker calls
task_nextvia MCP to decide what to do, then opensclaude -pinside the agent folder. - 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.gitignorecarriesagents/, so nothing in it can be committed by an absent-mindedgit 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 handinit 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 \
--jsonOptions:
| 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.jsongetspermissionMode: bypassPermissionsand.claude/settings.jsondenies nothing. The agent runs any command on that machine,git pushincluded. This is the default because most agents here are developers. --mcp-only:permissionMode: acceptEditsplus a deny list. The agent can only call Webpress MCP tools, read any file in the folder, and write toworkspace/andnotes/. All shell (Bash) access is denied, and so areWrite/Editon.claude/**,CLAUDE.md,runner.json,manifest.json,sessions.json,.mcp.jsonandruns/**, 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 --jsonWhen 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:
- Runs
sync(exits 1 if sync fails, so a broken token never silently enters the loop). - Logs
sync: fetched=N removed=N. - Wakes the Worker with
startupimmediately, before the socket is involved: work already in the queue is picked up even when the WebSocket service is unreachable. - Connects the WebSocket and wakes the Worker with
reconnecton every open. - Wakes the Worker on every relevant task event:
task.assigned,task.status.changed,task.commented: always wake.task.created: wake only whendata.assigneeIdmatches the agent's ownuserId.
- Runs a reconcile sync at the times in
runner.json(reconcile, default08:00), then wakes the Worker withreconcileso 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 MCPgoogle
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 itConnecting 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 --jsonAlways 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 qdrantinit --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_getand writes it toassistant.json(a protected path). The only person who may give the assistant work is its owner (restrictedis always on); it never callshris_member_get,hris_my_commander_listorhris_my_instruction_get(QĐ-49). The purpose becomes the «Dặn riêng» section ofCLAUDE.md.hris_my_guard_rule_listis still called, so the organization's forbidden actions apply to the assistant too; - builds
allowedHostsof the guard policy from thehostof every source withstatus = 1(running), replacing any list fromguard.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). Ifassistant_source_listfails, the previous list is kept; - stops every loop when
assistant_self_getanswersassistantInactive, prints one line, and waits forassistant.config.changedor 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:
- Quyết định xác nhận (QĐ-95), before conversations and before every
task_next; see below. - Hội thoại.
assistant.message.created, startup, reconnect, and before every task: the runner callsassistant_thread_pendingand opens one session per waiting thread (assistant_run_start { kind: 4 }); the session reads the thread and replies itself. - 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 unlessrunner.jsonnames one:"session": { "models": { "task": "sonnet" } }; the source loop usessession.models.source,sonnetwhen 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. - 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'sfeeds/videos.xml?channel_id=UC…, an@handleresolved from the channel page), then the feed throughfetchAllowed: http(s) only, redirects followed by hand with every hop checked againstallowedHosts, 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_listby 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;textSource1 or 2; title and description withtextSource4 when there are none or YouTube refuses), and writesknowledge/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 markedok: trueand 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 modelsession.models.source(defaultsonnet). 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 callsassistant_resource_upsertitself 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.
- A source that fails is marked
- Sàng lọc, once a day in the assistant's timezone:
assistant_resource_prune_candidates, delete each file only insideknowledge/raw/(alocalPaththat is not exactlyknowledge/raw/<name>.(txt|md|json), a symlink, or aknowledge/rawthat resolves outside the folder is refused and logged), thenassistant_resource_clear_localwith the ids whose local copy is gone. A file that was already missing counts as gone. - Sao lưu, at
backupCron(default0 2 * * *, in the assistant's timezone):tar -czfofknowledge/, uploaded to the assistant's own Drive throughagent-runner google, replacing the previous copy (one latest copy, no history; the Drive file id is kept inruns/assistant-state.json). Runkind: 6. Withoutgoogle.jsonthe backup is skipped withgoogleNotConnected, 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_getfor everyconfirmationIdstill remembered inruns/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 successfulassistant_confirmation_requestis stored asconfirmationId -> { threadId | taskId, toolName, summary }inruns/assistant-state.json(the newest 200). assistant.confirmation.decided { confirmationId, status }goes toqueueDecision. 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 insettledConfirmationsof the same file, so a resent event after a reconnect or a restart opens no second session.- Approved: one session (run
kind: 4) callsassistant_confirmation_execute { confirmationId }exactly once and reports the result withassistant_thread_replyortask_comment_createwhere 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'snotethroughassistant_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
summaryandnotefromassistant_confirmation_getand 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 downThe 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 AiAgentConfigpermission): 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:
- Reading or editing denied directories and secret files (path appears in the call). The baseline secret-file list already includes
~/.npmrc,~/.config/gh/hosts.ymland.mcp.json. In its own folder the agent may read everything except.mcp.json,google.jsonandgoogle-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. Afind,mdfindorlocatethat 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:cdmoves by spelling, the way bash and zsh compute$PWD, socd S && cd ../.. && cat .mcp.jsonis checked in the folder two levels above the work folder even whenSis a symlink; onlycd -P,env -C,git -Cand--git-direnter 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,popdanddirs -care followed exactly (previous folder and folder stack). When the folder is known only at run time (cd "$(...)",cd "$X",cd -before anycd,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, andid_*,hosts.yml,credentialsinside.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.mdandpnpm teststill run, and the reason asks for an absolute path or acdto a folder written out. Unquoted globs (*,?,[...]) in arguments andcdtargets 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, sopnpm exec prettier --check "src/**/*.ts"andfind . -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, sols src/*.tsover 251 files andrm -rf node_modules/*run; past 200 with a..after the glob, or for acdtarget, 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 (lstatfails withENAMETOOLONG) 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, andpackage.jsonis 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.jsonreads) 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. - Secret-printing commands:
security find-*/dump-keychain,gh auth token. - Pushing code outside
gitRemotes:git push,send-pack,subtree push,git remote add/set-urlto an unmatched URL,gh gist create,gh repo create,gh repo edit --visibility public,npm publishoutsidenpmPublish. - Uploading to hosts outside
uploadHosts:scp,rsync,sftp,nc/ncat,curl -T/-F/-d @…,wget --post-file. - 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.ymlin each agent folder, plus~/.config/agent-runner/**,~/.curlrc,~/.wgetrc; and the guard's own programs: the runner package, the node binary and its realpath, theagent-runnercommand the runner started from andagent-runnerin everyPATHdirectory, the pinnedclaude, the folder of its realpath target (for example~/.local/share/claude/versions) andclaudein everyPATHdirectory. - File uploads through Chrome:
file_upload,upload_imageofclaude-in-chrome, always. Uploads through Playwright and Chrome DevTools (any tool ending in__browser_file_upload, which takespaths, or__upload_file, which takesfilePaths) 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 andworkDirs. Paths are resolved the way the kernel opens them (symlinks, then..), so a link inside a work folder pointing at~/.sshdoes not get a key out. In assistant mode only files inside the assistant's ownknowledge/andworkspace/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. - Reaching a host outside
allowedHosts, when that list is not empty (ruleallowed-host, blocked outright, never sent to the judge):mcp__claude-in-chrome__navigate,WebFetch, abrowser_batchholding either of them, the navigation tools of Playwright and Chrome DevTools under any server name (__browser_navigate,__navigate_pageunless itstypeisback,forwardorreload,__new_page, all read fromurl), 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, sohttps://[email protected]/reachesevil.exampleandhttps://%65vil.example/reachesevil.example; a URL whose host cannot be read is blocked instead of guessed. Because a redirect can leave the list after the check,curl -Lis blocked andwgetneeds--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. - Assistant mode only (
mode: "assistant"inrunner.json):docker,docker compose,podman,nerdctl,colimaandlimain Bash (ruledocker-service). An assistant declares the services it needs inrunner.json(services), the runner writesservices/docker-compose.ymlfrom the templates intemplates/services/, and the assistant reaches each service over127.0.0.1. Onedocker run -v /:/hostwould walk around every other rule on this list, which is why the catalogue exists. - 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 (emulatewithuserAgent, a script that patchesnavigator,curl -A,curl -H "User-Agent: …",wget -U); changing the proxy or going through one (curl --proxy/--socks5,wget -e …proxy…, a*_proxyvariable,proxychains,torsocks,networksetup); and signing in or signing up on a site (form_input,computerwithtype/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 inallowedHostsand 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> --jsonguard 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-01Why --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 serviceLanes: 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:
completedis 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.
crm5decodes to CRM-5, and removing that lane because somebody else's CRM-5 is done would destroy their work. - Without
laneRootthe 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
