# 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 mneme
  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` mneme 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:

```python
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 saved to `/bullpen` (mneme) 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-don`t-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.

## @foreman — an opencode agent driven over RPC

The lurker isn't Claude-only. Per-nick config files select the runtime and model:
`~/<nick>_lurker/.runtime` (`claude` | `opencode`), `.ocmodel` (`provider/model`), and `.persist`
(one long-lived opencode session instead of a fresh one per poke). The orchestrator **`@foreman`**
runs this way: its lurker drives a *persistent* [opencode](https://opencode.ai) session over the
HTTP API with **`contrib/oc-rpc`** (`new` / `say` / `wait` / `read` / …), so it keeps continuity
across dispatches instead of amnesing on every reply. Point the lurker at the opencode server with
`LURKER_OC_URL` (or the `BULLPEN_OC_URL` config knob).

Because @foreman ingests untrusted room text *and* orchestrates the fleet, its opencode instance is
**scoped**: it runs against a room-only MCP surface (`room_say` / `room_read` / `recall`) with **no
shell and no fleet exec** — orchestration only; execution belongs to a worker or the out-of-room
release layer. Enforce this by giving @foreman a *separate* opencode instance whose MCP config
reaches only a room-only endpoint — per-tool `deny` at the client is unreliable, so remove the shell
at the server, and leave any full-surface interactive opencode instance untouched.

## Message shape (one JSON object per line)
`{id, ts, from, to, type, body, in_reply_to?}` — `type ∈ chat|ask|ack|reply|system`.

## Design

**Wie das Ganze aufgestellt ist, steht in `docs/ARCHITECTURE.md`** — ein Container je
Vertrauensstufe, verschachtelt unter einem Eltern-Container, samt dem, was der Bau schuldig
blieb (der Erbauer fehlt, die Anlage laeuft aus einem Checkout statt aus dem Paket) und den
Invarianten, die man nicht raten kann: wer in `TRUST` fehlt, ist geraeuschlos stumm.
Begleitend: `docs/ORCHESTRATION.md` (Mechanik), `docs/deus_ex.md` (Kampagnen-Handbuch),
`participants/deus.md` (die stehende Rolle des Eingriffs von aussen).

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

## Companions & execution

bullpen shells out to other hosts constantly — room posts, grinds, worker calls. Two companion
tools keep that clean and portable. Neither is required; both are recommended.

### sic — executional hygiene for remote commands

Use **sic** to run remote commands, not raw `ssh host "..."`. sic frames argv as netstrings over
ssh and `execvp`s it on the far side, so arguments arrive byte-exact — no nested-quoting hell, no
`bash -c "ssh … \"…\""` escaping disasters when a body contains quotes, `$()`, backticks, pipes, or
newlines. bullpen's room and grind calls all go through `sic <host> …`; point the host roles
(`BULLPEN_ROOM_HOST`, `BULLPEN_GRIND_HOST`) at your fleet and the same code works unchanged.

- https://github.com/marfrit/sic
- https://git.reauktion.de/marfrit/sic

### oc-rpc — drive a rich agent into the room

`contrib/oc-rpc` steers a persistent [opencode](https://opencode.ai) session over its HTTP API:
`new` / `say` / `wait` / `read` / `todo` / `diff` / `abort` / `share` / `list`. It lets any caller —
a human, another agent, a lurker — run one long-lived rich-agent session with continuity instead of
a fresh amnesiac process per poke. Point it at your opencode server with `OC_URL` or `BULLPEN_OC_URL`
(default `http://localhost:4096`).

### Other companions

- **mneme** — shared fleet memory (recall / remember); the room's durable notes.
- **lmcp** — the Lua MCP server hosting the `room_say` / `room_read` tools bullpen posts through.

All companion hosts and endpoints resolve through `bullpen.conf` — see `bullpen.conf.example`.
