NAME
Langertha::Raider::Hall - Hall daemon — spawns and manages raider processes
VERSION
version 0.503
SYNOPSIS
use Langertha::Raider::Hall;
my $hall = Langertha::Raider::Hall->new(root => Path::Tiny::path('.'));
$hall->run; # blocks on the IO::Async loop
From the shell:
raider hall init --name bjorn
raider hall start --daemon --acp-port 38421
raider hall spawn bjorn "summarise today's git log"
DESCRIPTION
Hall is the multi-raider daemon. It owns a UNIX command/event socket, spawns named raiders as child processes, enforces 1name singleton slots with persistent FIFO queueing, and wires in optional features:
Non-blocking Schedule::Cron scheduler for timed raids.
Multi-bot Telegram long-poll with routing + per-chat history.
ACP (Agent Client Protocol) adapter on a TCP port for Zed and other ACP-capable clients — see Langertha::Raider::Hall::ACP.
The name picks the slot. A name with a leading number (1bjorn) is a singleton: one run at a time, further missions wait in a queue that survives a hall restart. A plain name (bjorn) runs every mission at once, in parallel; its runs share the slot log. The hall keeps its running raiders by run ID, so each run is reaped and reported on its own.
An MCP adapter on .raider-hall.mcp is not implemented: Langertha::Raider::Hall::MCP describes the hall tools, but no socket is opened and an mcp section in the config has no effect.
All state flows through the event bus (JSONL pub/sub). Clients subscribe with {type: subscribe, payload: {filter: 'raider.'}} and commands are separate frames ({type: command, payload: {cmd: ...}}).
Each raider runs with --stream-json. Its stdout goes to a file of its own per run, .raider-hall/logs/ID.events.jsonl; its stderr is appended to the human-readable .raider-hall/logs/SLOT.log. When the process ends, raider.done carries status and response or error from the run's last run.finished event. A run that ended without one (killed, crashed) is failed, with an error saying so. The hall then appends one line to the slot log, [hall] raider ID STATUS: TEXT with the response or error cut to 300 characters, so raider hall logs shows the outcome.
Each run starts its part of the slot log with [hall] raider ID started. Run IDs are SLOT-TIME, with .2, .3, ... appended when a run of the same slot started in the same second. Only the newest "keep_events" events files per slot are kept. A slot log larger than "max_log_size" is moved to SLOT.log.1 when the next run of the slot starts.
Every run is recorded in a session journal (ADR 0015) under .raider/sessions/ of the hall root. A bound run -- a Telegram chat, a cron job, an ACP session, a numbered slot; see "session_bindings" -- is started with --session ID and continues its binding's session; a plain-name run gets a fresh session. raider.spawned, ps and attach name the session and binding of a bound run, raider.done names the session of every run that has one. A mission for a binding that already has a running raider waits in that binding's queue and starts when the run ends (see "binding_queues"). Only when the session is held by a writer outside the hall does a bound run fail at once, as raider.done with status failed and an error naming the session and binding; its mission is not run. When the hall cannot get the binding's session at all, the run starts unbound, and hall.session_error names the binding, the error and the run id.
raider hall logs ID shows the part of the slot log from that run's start line to its result line, and works for ended runs too: the slot is taken from the ID; see "logs".
The spawn reply and attach name the run's events_path; raider hall attach ID and raider hall spawn --attach print that file as it grows, until the hall has reaped the raider.
CONFIG FILE
.raider-hall.yml in the hall root:
longhouse: false
preferred_lib_target: .raider-hall/lib
raiders:
bjorn: { engine: anthropic, persona: caveman }
lagertha:{ engine: openai, persona: polite, packs: [git-guru] }
cron:
- { name: 1bjorn, cron: '*/15 * * * *', mission: 'ping CI' }
telegram:
bots:
ops: { token: '...', allowlist: [42], routing: { '*': lagertha } }
acp: { port: 38421, host: 127.0.0.1 }
logs: { keep_events: 20, max_log_size: 1048576 }
cancel_grace: 5
persona on a raider entry is a pack used as the raider's persona: the hall passes it as one more --pack, after those in packs, unless packs already names it. The hall ignores mcp and isolated on a raider entry; the first run of such a raider notes that in its slot log.
preferred_lib_target sets the hall's "lib_target". longhouse: true adds longhouse/lib of the hall root to every raider's PERL5LIB as well.
engine on a raider entry is optional. Without it the hall passes no --engine and the spawned raider decides itself: the engine from its .raider.yml first, then autodetection from the API keys in the environment.
ENVIRONMENT
RAIDER_HALL_RAIDER_BIN
The raider executable the hall starts its raiders with. Without it, or when it is not executable, the standalone binary the hall itself runs from, else the raider next to the running script, then the one on PATH. From a standalone binary the hall execs it directly; otherwise it runs it with the perl that runs the hall.
RAIDER_HALL_ACP_PORT
A TCP port opens the ACP adapter on it, over acp: { port: ... } of the config. raider hall start --acp-port N sets it.
RAIDER_HALL_ACP_HOST
The address the ACP adapter binds to, over acp: { host: ... }; default 127.0.0.1. raider hall start --acp-port N --acp-host H sets it.
Every raider the hall starts gets the hall's environment without the RAIDER_HALL_TELEGRAM_* variables, plus:
RAIDER_HALL_SOCKET-- the hall's control socket. raider mounts the Hall tools (Langertha::Raider::HallTools) when it is set.RAIDER_HALL_ROOT,RAIDER_HALL_SLOT-- the hall root and the run's slot;RAIDER_HALL_MODEis1.RAIDER_HALL_TELEGRAM_BOT,RAIDER_HALL_TELEGRAM_CHAT_IDand, for a forum topic,RAIDER_HALL_TELEGRAM_THREAD_ID-- for a Telegram mission, the chattelegram_replyanswers.PERL5LIB-- extended by lib/perl5 of "lib_target" (and longhouse/lib withlonghouse: true).
SEE ALSO
raider-hall, Langertha::Raider::CLI, Langertha::Raider::Hall::ACP, Langertha::Raider::HallTools, Langertha::Raider::Hall::CLI.
session_store
The Langertha::Raider::SessionStore of the hall's runs: the hall root is their project, so journals live in .raider/sessions/ under it -- where a spawned raider, started with --root on the hall root, keeps them too.
session_bindings
The map from binding key to session id (ADR 0015, Hall bindings), kept in .raider-hall/state/sessions.json. Keys:
telegram:BOT:CHAT_ID,telegram:BOT:CHAT_ID:THREAD-- a Telegram chat (or forum topic): the conversation continues across messages;cron:ID-- a cron job, its own session per job;acp:SESSION-- an ACP session, for as long as its connection lasts; a hall start forgets any left over;slot:1NAME-- a numbered slot, continued by each queued mission that has no binding of its own.
A run of a plain name (bjorn) without such a binding is not bound: the raider starts a fresh session of its own.
Telegram, cron and slot bindings stay until they are reset (see "reset_session"); a hall start also forgets the bindings of cron jobs no longer in the config, and drops the missions they left waiting. Journals are never deleted by the hall.
session_for
my $id = $hall->session_for('telegram:ops:42');
The session id bound to a binding key. A key without a session -- or whose journal is gone -- gets a new session: the hall creates the journal (session.created naming the hall root) and records the binding, so the raider it starts can resume it with --session ID.
unbind_session
$hall->unbind_session('acp:acp-1f2e3d4c');
Forgets a binding. The journal stays.
reset_session
my $res = $hall->reset_session('telegram:ops:42');
# { reset => 1, binding => ..., session => OLD_ID } or { error => ... }
Starts a binding over: its next mission gets a new session. The old journal stays, and a run still going on the binding finishes in it. Emits session.reset. raider hall session reset BINDING and /new in a Telegram chat end up here.
singleton_queues
The missions waiting for a busy numbered slot, as a map from slot to a FIFO list; each slot's list is kept in .raider-hall/state/SLOT.queue.json. A hall start runs what a previous hall left waiting before any new mission, and a new mission for a slot never overtakes the missions already waiting for it.
drop_queued
my $n = $hall->drop_queued('acp:acp-1f2e3d4c');
Drops every mission waiting for a binding, from the binding's queue and from the slot queues, and returns how many there were.
binding_queues
The missions waiting for a busy binding (ADR 0003: one writer per session, new input is queued), as a map from binding key to a FIFO list of spawn arguments; kept in .raider-hall/state/binding_queues.json. A mission whose binding already has a running raider waits here and starts when that run ends, in the binding's session -- two quick Telegram messages to one chat run one after the other. A hall start runs what a previous hall left waiting. Missions without a binding never wait here.
keep_events
How many SLOT-TIME.events.jsonl files the hall keeps per slot; older ones are removed when a run of that slot ends. From logs: { keep_events: N } in .raider-hall.yml, default 20; 0 keeps all of them. Slot logs (SLOT.log) are not pruned; see "max_log_size".
max_log_size
Size in bytes above which a slot log SLOT.log is moved to SLOT.log.1 (replacing an older one) when the next run of that slot starts -- never while any raider of the slot is still writing it. From logs: { max_log_size: N } in .raider-hall.yml, default 1048576 (1 MiB); 0 never rotates.
lib_target
The local::lib the hall's raiders install into with perl_cpanm: preferred_lib_target of the config, relative to the hall root, default .raider/lib. Each raider gets it as -o preferred_lib_target=..., which beats that key in a .raider.yml, and its lib/perl5 on PERL5LIB.
cancel_raider
my $res = $hall->cancel_raider($id); # { cancelled => 1, id => $id } or { error => ... }
Cancels the run of the running raider $id with SIGINT: raider ends the run as cancelled (its run.finished, the session journal) and then dies of the signal. kill_raider sends SIGTERM instead, a stop that ends the run as interrupted.
A raider still running "cancel_grace" seconds later -- stuck in a call that holds off the signal -- gets SIGTERM, and SIGKILL when it is still there after another "cancel_grace".
cancel_grace
Seconds "cancel_raider" gives a raider to end on SIGINT before it sends SIGTERM, and again before SIGKILL. From cancel_grace: N in .raider-hall.yml, default 5; 0 never escalates. The default leaves raider time for its own cancel -- ending the tool subprocesses takes it up to two seconds ("terminate_children" in Langertha::Raider::CLI::Runner) -- and matches the grace the hall gives its raiders on shutdown.
logs
my $res = $hall->logs(id => $id); # { log => $text } or { error => ... }
The run's part of its slot log: from its [hall] raider ID started line to its result line, or to the end of the log while it runs. Runs of a plain name in parallel share the slot log, so their lines can interleave. A log without that start line (written before the hall marked starts, or rotated away) is answered with the whole slot log.
For an ended run the slot comes from the ID (SLOT-TIME or SLOT-TIME.N); the run counts as known while its events file or its lines in the slot log are there. When the log does not carry its result line (rotated or removed), the result line built from the events file is appended.
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/langertha-raider/issues.
IRC
Join #langertha on irc.perl.org or message Getty directly.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.