NAME

Langertha::Raider::Hall - Hall daemon - spawns and manages raider processes

VERSION

version 0.504

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) on the control socket; see "EVENT BUS" and "CONTROL SOCKET", both public wire formats (ADR 0033).

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.

EVENT BUS

The hall writes every event as one line of JSON, with sorted keys, to each control-socket client that subscribed with {"type":"subscribe","payload":{"filter":PREFIX}}; the client gets the events whose type starts with PREFIX ("" for all of them).

Envelope, version 1. Every event carries:

  • version -- the format version, the integer 1.

  • type -- one of the types below.

  • seq -- 1, 2, 3 ... per hall process.

  • time -- epoch seconds as a float.

  • ts -- the whole seconds of time; kept in version 1 for subscribers of before.

Within a version fields and types are only added, never renamed, removed or changed in meaning; a subscriber ignores what it does not know (ADR 0013). A field in [brackets] below is there only when it applies.

hall.started -- root: the hall root.
hall.stopping -- the hall shuts down; no fields.
session.bound -- binding, session: a binding got a new session.
session.reset -- binding, session (the old one): see "reset_session".
hall.session_error -- binding, error, id: the run id could not get its binding's session and runs unbound.
raider.queued -- slot, queue_depth, [binding]: a mission waits for its numbered slot, or (with binding) for its binding.
raider.spawned -- id, pid, slot, base_name, [session, binding].
raider.refused -- status (refused), slot, name, [binding], reason, error, queued: the hall refused the mission's raider entry, so nothing started and the mission is not run. reason is a code: provider_not_alias (a host or URL, not an alias), provider_with_engine (provider and engine on one entry), provider_not_stored (the alias is not in ~/.raider/config.yml), provider_config (that file cannot be read); more may be added in version 1. error is the human text. queued is true when the mission was refused as it was pulled from a queue. There is no id and no pid: a refused mission never ran.
raider.done -- id, slot, pid, exit_code, signaled (1 or 0), status, [response], [error], [session], [binding]: a run ended; see "DESCRIPTION".
cron.fired -- id, name, [raider_id]: a cron job's occurrence was handed to the hall; raider_id when a run started at once.
cron.coalesced -- id, name: an occurrence dropped because the previous one has not ended (coalesce: true).
acp.started -- host, port: the ACP adapter listens.
telegram.in -- bot, chat_id, [message_thread_id], text, first_name, username, update_id: an accepted message.
telegram.rejected -- bot, chat_id, from_id, reason (no_sender, sender_not_allowed, chat_not_allowed), update_id.
telegram.config_warning -- bot, warning.
telegram.poll_error -- bot, [status], error.
telegram.update_error -- bot, update_id, error.
telegram.send_error -- bot, chat_id, error: a reply could not be sent.

CONTROL SOCKET

.raider-hall.socket in the hall root (mode 0600) takes one JSON object per line: subscribe (see "EVENT BUS") or {"type":"command","payload":{"cmd":NAME, ...}}. Every command is answered with one line of JSON, with sorted keys; an unknown command, an unknown frame or a command that died is answered {"error":...}.

spawn -- name, mission. Answers id, pid, slot, events_path, [session, binding] for a started run; queued (1), slot, queue_depth, [binding] for one that waits; status (refused), reason and error for a refused one (the codes of raider.refused).
ps -- raiders: a list of slot, pid, base_name, mission, id, [session, binding].
attach -- id; see "attach".
kill -- id; killed and id, or error.
logs -- id; see "logs".
status -- running, root, slots.
session_reset -- binding; see "reset_session".
telegram_reply -- bot, chat_id, text, [message_thread_id]; ok, or error.

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
permission_timeout: 120

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.

permission_mode on a raider entry (default, acceptEdits, auto, plan, bypassPermissions; ADR 0021) is passed as --permission-mode. A hall raider has nobody to ask, except a run of an ACP session whose client can be asked with session/request_permission (see "permission_timeout"): every other tool call its mode or rules ask about is refused with the reason. Without the key the raider takes the mode from its project (permissions.default_mode of .raider/config.yml, the Claude settings), else default -- which refuses writing, bash and the network unless a rule allows them.

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.

provider on a raider entry is the alias of a provider binding that raider provider add ALIAS HOST stored in ~/.raider/config.yml (ADR 0007); the hall passes it as --provider ALIAS, and model next to it as --model, which picks the model among the manifest's. Only an alias: a host, HOST:PORT or https origin is refused, and so is an alias that is not stored -- the hall runs unattended and never fetches a provider nobody accepted, so it never passes --allow-internal either (that is part of the binding). provider and engine on one entry exclude each other. A refused entry starts no raider and its mission is not run -- also one that waited in a queue and is refused when it is pulled: the hall emits raider.refused (see "EVENT BUS"), the spawn answers with status: refused, a reason and an error, and the slot log gets a [hall] raider NAME refused: WHY; mission not run line. ACP sessions of such a raider run on the same provider.

raiders:
  ivar: { provider: acme, model: example-model, persona: caveman }

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_MODE is 1.

  • RAIDER_HALL_TELEGRAM_BOT, RAIDER_HALL_TELEGRAM_CHAT_ID and, for a forum topic, RAIDER_HALL_TELEGRAM_THREAD_ID -- for a Telegram mission, the chat telegram_reply answers.

  • PERL5LIB -- extended by lib/perl5 of "lib_target" (and longhouse/lib with longhouse: 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.

permission_timeout

Seconds an ACP client has to answer a session/request_permission the hall forwards for a raider's tool call (ADR 0021, Nachtrag P2). From permission_timeout: N in .raider-hall.yml, default 120. After it the answer is no: the call is refused, the run goes on, and the hall does not treat a run that waits for an answer as hung.

register_permission_handler

$hall->register_permission_handler( 'acp:acp-1234', sub { my ($question) = @_; return $future_of_answer } );
$hall->unregister_permission_handler('acp:acp-1234');

A surface that can ask a person (the ACP adapter) registers, per session binding, the code that answers the questions of the runs of that binding. A run spawned for a registered binding gets a permission channel (Langertha::Raider::Hall::PermissionChannel) and the hidden raider option --permission-channel; every other run has none and is refused as before. The code returns a Future of { answer => 'once' | 'session' | 'no', reason }.

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

https://raudssus.de/