Imported from AlexanderShvaykin/agent-daemon-rb (
AGENTS.md). Install upstream withnpx skills add AlexanderShvaykin/agent-daemon-rb. Copyright stays with the author.
CLAUDE.md
This file provides guidance to Code Agents when working with code in this repository.
What this is
agent_daemon is a packaged Ruby gem: a daemon that orchestrates CLI AI agents (Claude Code, OpenCode). It runs one thread per configured runner, each with a trigger (Yandex Tracker query, file polling, or Mattermost @-mentions), a backend, and a prompt template, plus one Messenger thread that delivers webhook notifications. The core daemon is stdlib only. Two narrow exceptions carry runtime gems, and both are confined to the subsystem that needs them:
eventmachine+faye-websocket— used only by themattermosttrigger, to handle the WebSocket protocol instead of hand-rolling RFC 6455.puma+rack+oauth2— used only by the supervisor's web console (lib/agent_daemon/supervisor/console/), authorized by NFR6/AD-6.test/test_require_isolation.rbis the guard:require "agent_daemon"must load none of them, and that is enforced, not aspirational.
Everything else stays stdlib. Do not add any other gems to the runtime path, and do not reach for these from outside the subsystem that owns them; minitest/rake are dev-only.
Commands
bundle install
rake test # full suite
ruby -Ilib -Itest test/test_config_defaults.rb # single test file
bin/agent-daemon config.yml # run the daemon against a config
gem build agent_daemon.gemspec # build the gem
There is no linter configured. Tests are Minitest (test/test_*.rb), no spec DSL.
Architecture
Read docs/architecture.md first — it is the authoritative design reference. Key points that span multiple files:
- Threads communicate only through the filesystem. Runners write YAML files into
message_dir; the Messenger polls that same directory and POSTs them to the webhook. There is no in-process queue or shared mutable state between runners. ShutdownFlag(daemon.rb) is an intentionally mutex-free boolean. It relies on MRI's GIL for atomic read/write. Every long loop (runner iteration, backend select-loop,wait_interval) polls it ~every 1s (0.5s in the backend) so shutdown is cooperative. Don't add locking around it or introduce blocking sleeps that ignore it.- Runner inheritance:
Runner::Baseowns the poll → process → attempt-tracking loop. Subclasses (Runner::Tracker,Runner::File) implement onlyfetch_work_items,work_item_key,render_prompt, plus optionalafter_success/after_failure/after_killed/after_exhaustedhooks.Runner::Mattermost < Runner::Filereuses the file-poll machinery wholesale and only overridesrender_promptto expose the listener's work-item fields as{{...}}vars.Runner::PachcaandRunner::GitHubare ordinary pollers likeRunner::Tracker. Add new trigger types here and wire them inDaemon#runner_factory_for.before_attemptis the one hook that fires before the backend — for telling a source it was heard, since a run takes minutes. - Mattermost trigger (push, not poll for delivery). A
mattermostrunner is split in two: aMattermost::Listenerreceives @-mentions over a WebSocket and writes<post_id>.ymlwork-items into an inbox, and theRunner::Mattermostfile-poll consumer picks them up. The listeners do not own threads — they run inside a single sharedMattermost::Reactor(Daemon#reactor_factory_for), registered as the:mattermost_reactorthread, a peer to the Messenger. There is exactly one reactor for all mattermost runners because EventMachine's reactor is a process singleton; it is restarted bymonitor_threadslike any other thread. The listener resolves its bot id (GET /api/v4/users/me) and team id (GET /api/v4/teams/name/{team}) beforeEM.runso the reactor thread never blocks on IO, then filterspostedevents (not-self + eventteam_idmatches the configured team + allowlisted channel + bot mentioned), de-dups by post id across inbox/done/failed, and reconnects with capped backoff (1s→30s, reset on the serverhello). The agent replies by writing a message YAML carryingchannel_id+root_id(see Config + the Messenger section indocs/architecture.md). - Pachca trigger (poll, and deliberately so). Pachca has no realtime API — outgoing webhooks and an event history endpoint are the only two ways in — and the history needs no public URL, so
Runner::Pachcais one class with no listener, no reactor and no new dependency.GET /webhooks/events+DELETEmake it a queue with an explicit ack:after_successdeletes, and so does an event the runner decided not to act on. That last part is load-bearing — every answer comes back as an event authored by the bot, and if nothing cleared those the history would fill until real questions fall off the first page. Consequently one bot token belongs to exactly one runner.trigger.bot_user_idis required for the same family of reasons: the agent replies into the chat it reads. - Backend factory:
Backend.for(...)dispatches on thebackendconfig key (claude,opencode,codex).FALLBACK_AGENT=1swaps in the runner'sfallback_agent— which is either another backend's name or a{command, args}Hash — and applies to every backend, since whichever agent a runner uses is the one whose quota can run out.Backend::Codexfixes--sandbox workspace-writeon purpose: the agent's only output is a file it writes, so read-only would mean runs that "succeed" having written nothing, and acknowledging triggers would discard the work. Backends run the CLI viaOpen3.popen3withpgroup: trueand return aResultwithreason∈:ok | :failed | :timeout | :killed. On timeout/shutdown the whole process group getsSIGTERMthenSIGKILLafter 2s. - Two independent failure counters: per-item attempts (
max_attempts, default 3 →after_exhausted) vs. consecutive trigger errors (MAX_CONSECUTIVE_ERRORS= 3 → writes aSYSTEM:<runner>error YAML tomessage_dirfor the Messenger to notify).:killedresults roll the attempt counter back (shutdown is not a failure). - Crash recovery:
Daemon#monitor_threadsrestarts any thread that died withThread.current[:crashed]afterRESTART_DELAY(60s). - Supervisor restart control: The authenticated console never manipulates threads directly. CSRF-protected
POST /restart— id and confirmation read from the form body only, CSRF token from the body or anX-CSRF-Tokenheader — calls the master-ownedRestartControl, which queues an actor-labelled intent onRunnerSupervisor; each generation gets a freshCancelTokenobserved separately from the process-wideShutdownFlag. Restart activity remains in-memory until Epic 5.
Config (config.rb)
Loaded from a YAML path (CLI arg to bin/agent-daemon). Config is validated eagerly in the constructor and raises ConfigError with all problems collected — preserve this fail-fast behavior. Note the path-resolution rules, which are easy to get wrong:
message_dir,output_dir, and file-triggerinput_dir/archive_dir/failed_dirresolve relative toproject_path. Themattermosttrigger shares the same three work dirs (viaresolve_trigger_dirs) and, when they are omitted, defaults them tomentions/<runner-name>/{inbox,done,failed}underproject_path.prompt_templateresolves relative to the config file's directory, and the resolved value is stored asprompt_template_path(this is the key the runner reads, notprompt_template).- Defaults live in
DEFAULTS/RUNNER_DEFAULTS/ trigger default constants; new config keys should get a default there and validation invalidate!.
The config file is rendered through ERB before YAML parsing (read → ERB.result(binding) → safe_load), so secrets can come from the environment via <%= secret('KEY') %> (fail-fast + .to_json YAML-safe quoting) or raw <%= ENV['KEY'] %> (lenient). Render-time failures are wrapped as ConfigError. The daemon stays sops-agnostic — operators populate ENV themselves (e.g. sops exec-env secrets.enc.yml -- bin/agent-daemon config.yml). See docs/secrets.md.
examples/config.yml is a fully commented reference.
Prompt templates
{{variable}} substitution. Variables come from: every key in the runner config hash, plus message_dir, optional output_dir, and trigger-runtime vars (task_key for tracker, input_file for file/mattermost). The mattermost consumer additionally exposes the work-item fields the listener captured: message, channel_id, root_id, sender, channel_name, post_id — so a mention prompt can quote the message and reply into the originating thread by writing a YAML with channel_id/root_id. Undefined {{...}} stay literal and log a warning — intentional, don't make them raise.
Conventions
- All files use
# frozen_string_literal: true. - This is a published gem — bump
lib/agent_daemon/version.rband updateCHANGELOG.mdfor releases.