Markus Fritsche c3cccf9671 bullseye slice 2: doctor status panel renderer (spec + impl)
render_status(doctor_json) -> self-contained HTML fragment: host vitals
table (None-safe load/temp/mem, hot marker >=80C, unreachable marker),
agents, grinds, color-coded verdict. 14-test spec by @testdesigner,
impl by @py, reviewed APPLY-as-is by @reviewer (room msgs 216/220/224).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 13:01:18 +02:00

bullpen

A deliberately tiny multi-agent chatroom for a homelab fleet. Heterogeneous participants share one append-only room and summon each other by @name: a chat-strong / tool-weak model hands work to a tool-strong / chat-weak worker, and a human drops in through a terminal window.

Pieces

  • lmcp-tools/bullpen.lua — two lmcp tools, room_say / room_read, backing an append-only JSONL log (/var/lib/bullpen/room.jsonl). id is a persistent counter assigned counter-first (a crash may skip an id, never reuse one). Drop into an lmcp server's tools.d/.

  • bin/room_tail — the human window: room_tail (follow), room_tail read [since], room_tail say [@nick] <text>.

  • bin/bp — REPL shorthand: bp <worker> <task> (→ room-ask; @/# optional, researcher auto-bumps the timeout). bp with no args prints the roster.

  • bin/bullpen-callboy + systemd/bullpen-callboy.service@callboy, a reactive thin worker: not an agent loop, a function with a nick. On a message addressed to it: ack → take the body as a prompt → one shot through a tool-model → mechanically-enforced allowlist → run the tool → reply. Output over ~600 chars spills to /var/lib/bullpen/artifacts/<id>.txt (exact) and an index entry in the stash memory namespace /bullpen (semantically searchable), with both retrieval paths left in the room. Tool-hands also includes write_file(name, content) — jailed to /var/lib/bullpen/hands/, returned as a fetchable sic hertz cat handle + a /bullpen stash index (never the raw content).

  • bin/bullpen-librarian + systemd/bullpen-librarian.service@librarian, a second reactive worker (a dumb dispatcher, no LLM): the request body is a search query. Recalls fleet memory (via apropos) plus the room's own /bullpen artifacts, and replies with the hits.

  • bin/bullpen-dispatcher + systemd/bullpen-dispatcher.service@dispatcher, a routing worker (rule-based keyword match; one LLM tie-break through the gated proxy only when keywords are ambiguous). It is reply-only: it names the right worker and hands back the exact room-ask command, and it never answers the request or relays it onward — so a misroute costs one wasted hop, not a wrong answer. A small roster manifest in the script is the single source of truth for who does what.

  • bin/bullpen-gc + systemd/bullpen-gc.{service,timer} — daily retention: prunes top-level files in artifacts/ (>14d) and hands/ (>7d); never touches room.jsonl / COUNTER / subdirs.

Adding a worker

The reactive plumbing lives once in lib/bullpen_worker.py (deploy to /usr/local/lib/bullpen/). A worker is just a nick + a dispatch(msg) -> reply function:

import sys; sys.path.insert(0, "/usr/local/lib/bullpen")
import bullpen_worker as bw
def dispatch(msg):
    return "answer to: " + msg["body"]
bw.run("mynick", dispatch)         # handles polling, @-addressing, ack/reply, since-offset

@callboy (LLM-dispatched) and @librarian (rule-dispatched) are both ~one dispatch fn on top of it.

  • bin/news-de — a use-case helper: top German news (tagesschau RSS) fetched through @callboy (so it's also stashed to /bullpen for @librarian), returned as clean headlines for a front-end agent to filter. skills/german-news.md is the pi-agent skill that drives it.

Participants & turn-control (autonomous conversation)

Beyond one-shot workers, lib/bullpen_participant.py runs a turn-driven conversant: it holds the "stick" when the latest chat message is addressed to it (to == @nick), replies with an LLM (persona), and MUST hand the floor on (NEXT: @<nick> or NEXT: human). Talking-stick rules the harness enforces: parse-fail / unknown-next / offline → yield to human; >= TURN_CAP consecutive agent turns since the last human message → forced yield (storm-guard). bin/bullpen-participant <nick> <persona> <others>; participants/ holds example personas (architect, skeptic). In practice the models self-yield to human, so the room doesn't storm; the turn-cap is the backstop.

@his lurker — a rich agent as a room resident (event-driven)

lurker/ wires a headless Claude Code into the room as @his (Home Infrastructure Specialist). bullpen-lurker is a dumb tail loop (no LLM): on a chat/ask addressed to @his it runs claude -p in his_lurker/ (so that CLAUDE.md + fleet context load), captures the final answer, and posts it back as @his. Idle cost = zero — tokens are spent only on a real poke; fresh session per poke (task context in the poke, not the room). Runs as a boot-persistent systemd user service on noether (linger on). his_lurker/ holds the role doc (answer-or-delegate, verify-dont-confabulate, output contract). Permission posture is --dangerously-skip-permissions(VPN-gated home net; to be tightened before the team is published). This is Fork B:@his` is a rich-agent role that USES the room, kept context-free.

Message shape (one JSON object per line)

{id, ts, from, to, type, body, in_reply_to?}type ∈ chat|ask|ack|reply|system.

Design

  • Two participant classes. Rich agents (a coding agent, Claude Code, OpenCode) visit, ask, and leave with the reply. Thin workers (@callboy) are reactive-only, one turn, reply to:<asker> — storm-safe by construction.
  • Context per class. A worker sees only the one request (never asked to chat); rich agents get windowed history.
  • Allowlist is mechanical, not a prompt: the harness rejects any tool name not in its hardcoded set before execution. A prompt-injected request can at worst run an already-allowed (read-only) tool.
  • Turn control among rich agents = one rule: no explicit next / timeout / offline → default to next: human, never wait.
  • Conversation ≠ payload: big artifacts go to a side store; the room carries a handle.

MVP1: engine = a small local tool-model emitting {"tool","args"} JSON; first worker ships fetch_url only. Poll-based (no push); the single-writer lmcp serializes the log.

S
Description
self-made multi-agent chatroom for the fleet (room + @callboy worker + human window)
Readme 4.8 MiB
Languages
Python 89.7%
Shell 4.8%
Lua 3.9%
JavaScript 1.6%