Skip to content

IRC agent orchestration

Status (as of 2026-06-16; reconciled against the live manifests 2026-06-18; workflow evolution 2026-06-21)

Implemented and deployed through step 10 of “Implementation order”; Plane is fully retired. The design body below is kept as-is for reference, but it reads in future (“to build”) tense — the system described is now live. Where a body detail (transport, sandbox egress, PVC lifecycle, external access, secrets) no longer matches what shipped, the “Divergences” list below is authoritative — check it before trusting a specific from the body. Progress against the 13-step implementation order:

#StepStateEvidence
1Deploy ergo✅ donek0s/vibes/ergo.yaml
2Deploy The Lounge✅ donek0s/vibes/thelounge.yaml
3agent-controller (!new/!tasks, METADATA)✅ donevibes/irc/agent-controller/, commit 638f119c
4agent-worker (IRC bridge + Pi core)✅ donevibes/irc/agent-worker/, agent-worker-template.yaml
5!assign spawns worker✅ donea8f58ded
6clarification flow (blocked / steer / followUp)✅ donepart of 638f119c
7!kill/!reopen/!archive + reaper sweep✅ donestale-worker sweep a8f58ded, pvc-reaper 5c2f3a69
8#firehose v1 publishing✅ donee50829e2
9dashboard backend (SSE — plan said WebSocket)✅ donevibes/irc/dashboard-backend/, dashboard.yaml, 81c348f0
10plain HTML status-grid frontend✅ done81c348f0
11notification-rule docs / push bridge⚠️ partialpush bridge not built; ntfy is a placeholder example, not a chosen tool (optional)
12run alongside Plane, then decommission↪️ diverged — see below638f119c, 389fc467
13timeline / animated (Godot) frontend⬜ not started (optional)

Divergences from the plan as written (what shipped vs. the body, and why):

  • Cutover was a hard retirement, not a parallel run. The “Migration from Plane” section describes deploying alongside Plane, running both for a release, scaling the old services to replicas=0 for a rollback window, then deleting. In practice Plane CE was removed and the stack retired outright (389fc467 removed Plane CE; 638f119c migrated the pipeline to ergo + The Lounge and retired the Plane stack). The parallel-run / rollback-insurance steps in that section did not happen — treat them as historical intent.
  • Image paths changed (2026-06 reorg). The three components now publish to ghcr.io/chnm/systems/vibes/irc/{agent-controller,agent-worker,dashboard-backend} (mirrored into the internal Zot at publish time). The “Migration from Plane” section only says “image repository paths” generically and predates this naming. Source dirs are vibes/irc/<name>/. agent-controller and dashboard-backend deploy :main; agent-worker deploys :latest (mutable, re-checked per pull — see the note in agent-worker-template.yaml).
  • Dashboard transport is SSE, not WebSocket. The Architecture diagram and the “Dashboard architecture” / frontend sections say the backend exposes a WebSocket. dashboard.yaml serves the status grid over Server-Sent Events on :8080, and the backend joins #firehose anonymously (no IRC account or secret — ergo’s :6667 allows unauthenticated reads), not as a logged-in dashboard nick. Why: the dashboard is a strictly one-way viewer (server→browser; every control action goes through IRC), so a unidirectional SSE stream is the simpler fit and needs neither a bidirectional channel nor a bot credential. Access is kubectl port-forward today; a public HTTPRoute (tasks.rrchnm.internal) is a noted follow-up.
  • Worker egress is a namespace-wide allowlist, far broader than “ergo + DNS + Anthropic only.” The Sandboxing section claims a per-worker policy admitting only ergo:6667, DNS, and the Anthropic API. In reality one CiliumNetworkPolicy (vibes-isolation, endpointSelector: {}) governs every vibes pod and allows DNS, same-namespace, OpenBao :8200, Garage :3900, thoth/Zot :443, the kube-apiserver entity, all of world on 80/443, the ingress Envoy, and backup2 :22. A worker can therefore reach the whole public internet, not just Anthropic. Why: (a) the policy is shared with the slack-bot stack and the backup CronJob, so it was written namespace-wide rather than worker-scoped; (b) agents genuinely need arbitrary HTTPS/HTTP to clone repos, run git/gh, fetch docs, and call third-party APIs — an Anthropic-only egress was impractical. The real guardrails remain the pod boundary, automountServiceAccountToken: false, and world excluding LAN entities.
  • Per-task PVC is garbage-collected, not deleted with the Job. Sandboxing says the worker’s PVC is “deleted when the Job finishes.” The controller creates pvc-task-<N> but its SA deliberately lacks PVC delete, and the Job TTL reclaims the Job but not the PVC — so a separate daily agent-pvc-reaper CronJob (its own SA with delete, reusing the controller image as agent-controller reaper) reaps idle orphans, DRY-RUN by default today. Why: keeping the destructive delete privilege off the long-running controller bounds its blast radius; the cost is one extra GC component (commit 5c2f3a69).
  • Worker root filesystem is writable. Sandboxing says “read-only root filesystem where possible.” The worker runs readOnlyRootFilesystem: false with a /workspace PVC plus a /tmp emptyDir. Why: the agent shells out to bash/git/gh, which write outside /workspace. (The controller and dashboard do run read-only-root.)
  • ergo is in-cluster plaintext only; no external TLS yet. The plan cites “6697 TLS for external clients” and a “Client choice” matrix of desktop / native / bouncer clients connecting directly. ergo exposes only :6667 plaintext as ergo.vibes.svc.cluster.local; the :6697 TLS listener is dropped and STS disabled. The only ways in today are The Lounge (web, behind its own Ingress/TLS) or kubectl port-forward. Why: external exposure was deferred — The Lounge covers the immediate need, and a TLS listener + Gateway/SAN wiring is a later phase. Treat the direct-client matrix as aspirational until 6697 lands.
  • Global concurrency cap now lives in BOTH the controller and the ResourceQuota. As of 2026-06-21 the controller enforces a hard MAX_ACTIVE_TOTAL=3 (in_progress tasks across all requesters), alongside the existing MAX_ACTIVE_PER_REQUESTER=5 and the 1-hour JOB_ACTIVE_DEADLINE_SECONDS. The namespace vibes-quota (pods: 30, persistentvolumeclaims: 10, plus CPU/mem) remains the cluster-side backstop. Why the change: the new auto-dispatcher (see “Workflow evolution” below) needs an application-level ceiling to decide how many ready tasks to start — the quota alone can’t gate dispatch. (Supersedes the earlier “quota only” note.)
  • Secrets are per-component via ESO; default model pinned. “Initial provider configuration” names a single Secret anthropic/api-key. In reality each component reads its own ESO-materialized Secret — agent-controller (IRC_SASL_PASSWORD; the controller logs into ergo via SASL as account controller) and agent-worker (ANTHROPIC_API_KEY, GIT_TOKEN) — and the VIBES_ env prefix was dropped (e9a26155). The assignment-pool default model is pinned to WORKER_MODEL=claude-sonnet-4-6, still per-task overridable.

Workflow evolution since v1 (2026-06-21). The task lifecycle gained two states and dropped manual assignment. This supersedes the Bot commands, Data model, and Event vocabulary tables below for current behavior; see vibes/irc/agent-controller/docs/task-states.{md,d2} for the full state machine

  • METADATA reference.
  • Lifecycle is now pending(draft) → ready → in_progress → for_review → done (plus blocked / archived). !new creates a draft (not yet visible to agents) and DMs the requester a join link; joining a draft channel shows a readiness checklist.
  • No manual assignment. A human prepares the draft and runs !ready; the controller auto-dispatches ready tasks (oldest-first) to available workers up to MAX_ACTIVE_TOTAL, refilling a slot whenever one frees. !assign survives only as an optional override to hand a task to a specific human.
  • Review gate. The worker submits [status] for_review (not done) and the pod exits; a human accepts with !complete (→ done) or returns it with !reopen (→ rework, respawn).
  • New task-channel commands: !ready, !complete, !desc [text] (show/replace the description while draft; frozen once ready). !tasks now accepts any status as a filter. The worker reads the description from a TASK_DESCRIPTION env var (controller-injected; CHATHISTORY is the fallback).
  • Friendly worker nicks from a fixed pool (HAL-9000, GLaDOS, …), first-free + recycled. The spawned-vs-human distinction moved off the agent- nick prefix onto a persisted spawned METADATA flag.
  • METADATA gained description, spawned, job, read-back of created (so task age / dispatch order survive a reconnect), and write-only <status>_at cycle-time markers.
  • Firehose gained task_ready, task_completed, task_updated; tool_call_start now carries the command in args. Corrections vs the v1 table: there is no heartbeat event (a worker re-emits [status] in_progress each turn → a status event with prev_status == in_progress); tool_call_end carries no duration_ms; worker_exited carries reason (e.g. vanished), not exit_code; the tool_call_start field is args, not args_preview.

What’s actually left (none blocking; core system in production use):

  • !find / free-text search — listed in the Bot-commands table but never built; still an open question (ergo history search vs. a sidecar indexer).
  • #firehose integrity hardening — the trust model assumes channel +m + a ChanServ sole-publisher ACL, but that is not enforced yet; today any client could post to #firehose. Real follow-up if the cluster is opened up.
  • Push bridge for [status] blocked/for_review (step 11) — still only a documented option (ntfy/Gotify/Slack), not built.
  • Dashboard frontend evolution (step 13) — timeline → animated/Godot; now reframed as a task-native web console, designed in plan-agent-task-console.
  • External access — ergo TLS :6697 + a public tasks.rrchnm.internal HTTPRoute; both deferred (The Lounge + port-forward cover today).
  • Declarative ergo seeding and the remaining Open questions (dependency graph, cost attribution) remain deferred.
  • Agent conversation (in main, not yet deployed) — memory (resume + ready-state notes), steering/!stop, linger-for-review, channel narration (tool calls moved to #firehose), and the CONTROLLER_NICK injection fix. Untested on a live cluster — see the “Agent conversation” section below.
  • Repo-scoped tasksbuilt 2026-06-21, awaiting deploy: bind a task to a Git repo (!hub/!repo + auto repo context to the worker, per-repo channel grouping with channel seeding), repo-less tasks still supported; one network, one firehose. Controller + worker unit-tested. See the “Repo-scoped tasks” section for what landed and what’s left.

Summary

Replace the Plane-based ticket workflow with a pure-IRC system where each task is a registered IRC channel. An agent worker is spawned per task as a k8s pod, joins the task’s channel, reads the description, does the work, and converses in-channel for clarifications. The channel is simultaneously the task record, the conversation, and the audit log.

No external task tracker. No web frontend. No database. The IRC server is the system of record; any IRCv3-capable client works, with The Lounge as a recommended PWA client for mobile.

Goals

  • One unit of work = one IRC channel. Conversation and task record are the same artifact.
  • Agents are ephemeral (one pod per task, scoped PVC, NetworkPolicy-isolated).
  • The IRC server is the only durable component. Everything else is replaceable.
  • No client lock-in: any IRC client (The Lounge, Quassel, irssi, weechat, HexChat, Halloy, mobile clients like Colloquy, etc.) can participate. Features are designed against the IRCv3 spec, not against a specific client.
  • Drop-in replacement for the existing Plane → k8s spawn pipeline; the agent pod’s task-tracker client swaps from Plane API to IRC.

Non-goals

  • Rich text, image rendering, inline diffs. Link to PRs / gists / pastes instead.
  • Kanban / Gantt / dependency-graph UI. If needed later, build as a small read-only bot exposing a web page from channel METADATA.
  • Multi-tenant / multi-org. Single workspace, single IRC server.
  • Replacing GitHub/GitLab PRs. Agents still open PRs there; IRC tracks the task, not the code review.

Architecture

┌──────────────────────────────────────────┐ ┌───────────────────┐
│ IRC clients │ │ Dashboard │
│ • The Lounge (web PWA, recommended │ │ frontend(s) │
│ for mobile) │ │ (HTML grid → │
│ • Quassel, irssi, weechat, HexChat, │ │ Godot WASM) │
│ Halloy, Colloquy, … │ └─────────┬─────────┘
└──────────────────┬───────────────────────┘ │ WS
│ IRC (6667 plaintext in-cluster, ▼
│ 6697 TLS for external clients) ┌──────────────┐
▼ │ dashboard- │
┌──────────────────────┐ │ backend │
│ ergo (IRC server) │ ◀── IRC: joins ─────────│ (subscribes │
│ channels, history, │ #firehose │ to #fire- │
│ METADATA, ACLs │ │ hose) │
└──────────┬───────────┘ └──────────────┘
│ IRC
┌─────────────┼─────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│agent- │ │agent-worker │ │agent-worker │
│controller│ │(pod per │ │ │
│(spawns │ │ active task) │ │ │
│ workers; │ │ │ │ │
│ sole │ │ │ │ │
│ publisher│ │ │ │ │
│ to │ │ │ │ │
│ #firehose│ │ │ │ │
│) │ │ │ │ │
└────┬─────┘ └──────────────┘ └──────────────┘
│ k8s API
k8s Job + ephemeral PVC per worker

The Lounge is deployed alongside ergo as a convenience: a self-hosted web client that gives anyone on the team a no-install browser/PWA path to the chat. It is not required. The Lounge’s only privileged property is that it speaks IRC — replace it with any other client and nothing else changes.

Components, in deploy order:

  1. ergo — IRCv3 server (history, METADATA, CHATHISTORY, registered channels). One pod, one PVC, BoltDB on disk.
  2. The Lounge (optional) — web IRC client behind an Ingress with TLS. PWA-installable. Recommended for mobile but not required; any IRC client that speaks RFC 1459 + IRCv3 capabilities works.
  3. agent-controller — connects to ergo as controller, listens in #tasks, spawns/kills worker pods, keeps channel METADATA consistent, publishes structured events to #firehose. (Renamed from the existing agent-dispatcher service.)
  4. agent-worker image — Pi agent core + minimal IRC client; one job is to bridge IRC ⇄ Pi and respect the task lifecycle. One pod per active task. (Renamed from the existing agent-coder service.)
  5. dashboard-backend (added in a later phase) — joins #firehose, maintains in-memory state, exposes a WebSocket to frontends.
  6. dashboard frontend(s) (added in a later phase) — start with a plain HTML status grid; evolve through timeline view to an animated workspace. Decoupled from backend; the event vocabulary is the contract.

LLM provider and agent framework

agent-worker is built on Pi (@earendil-works/pi-agent-core) — the agent toolkit already in production use elsewhere in our stack with Anthropic API keys. Continuing with Pi keeps us on a single known-good framework rather than introducing a second one for this system.

Pi gives us, in one library:

  • Provider abstraction. getModel("anthropic", "<model>") today; any other provider Pi supports tomorrow. Switching providers is a config change, not a code change. No external proxy required.
  • Agent loop with tool execution. Pi runs the assistant ↔ tools loop; we register IRC-bridge and task-specific tools as Pi tools.
  • Event streaming. text_delta events for token-by-token output to IRC, plus structured events for tool calls and turn boundaries.
  • Steering and follow-up queues. agent.steer(text) injects a message after the current tool call completes; agent.followUp(text) injects after the agent is idle. This is exactly the model our clarification flow needs — we don’t reimplement it.
  • Custom message types via declaration merging, useful for stashing IRC-specific metadata (nick, timestamp, channel) on messages without feeding it to the LLM.

Pi is intentionally minimal: no MCP, no sub-agents, no plan mode, no built-in permission UI. Features other tools bake in are built as Pi extensions or skills. For us:

  • Permissions are enforced by an IRC-bridge extension that intercepts tool calls and posts [PERM <id>] to the channel.
  • Sandboxing is the k8s pod boundary plus NetworkPolicy, not per-call popups. Pi runs with the worker pod’s full privileges, which is appropriate because the pod itself is the sandbox.
  • Sub-agents are not used initially. If needed, they’d be additional Pi instances spawned in the same pod or separate worker pods.

Initial provider configuration

  • Default provider: Anthropic, via our existing API key (Secret anthropic, key api-key).
  • Model: configurable per-task via a [meta] model=<name> directive on spawn, with a sensible default for the assignment pool.
  • Falling back to or adding non-Anthropic providers is a getModel() argument change plus a Secret for the new provider’s key.

What this doesn’t include

Claude Code’s specific prompts, slash commands, plan mode, and hook system are not part of Pi. If we want those, we either build them as Pi extensions or use them out-of-band (e.g. running pi-coding-agent as a separate sibling for developer-facing coding tasks while agent-worker handles automation). Initially, the four default Pi tools (read, write, edit, bash) plus our IRC bridge are enough.

┌─────────────┐
!new <desc> │ pending │ (channel created, no agent yet)
─────────────▶ │ │
└──────┬──────┘
│ !assign or auto-assign
┌─────────────┐
│ in_progress │ (worker pod spawned, joined channel)
└──┬───────▲──┘
│ │
worker posts │ │ human replies in channel
clarification │ │
▼ │
┌─────────────┐
│ blocked │ (channel mode +m, status=blocked)
└──────┬──────┘
│ worker completes
┌─────────────┐
│ done │ (topic prefix [done], pr link in meta)
└──────┬──────┘
│ !reopen
in_progress

Status lives in two places, kept in sync:

  • Channel topic prefix[in_progress] fix the flaky checkout test — visible at a glance to any client.
  • Channel METADATA key status — machine-readable, queryable by bots.

The agent-controller is the only authority on status transitions. Workers request transitions; the controller applies them. (Throughout this document, lowercase controller is the IRC nick used by the agent-controller pod; lowercase agent-N is the nick pattern used by agent-worker pods.)

Data model — what lives where

ConceptStorageSet byRead by
Task IDChannel name #task-<N>controller on !neweveryone
TitleChannel topic (after status prefix)controller, workereveryone
DescriptionFirst message in channel, by controllercontroller on !newworker on join
StatusTopic prefix + METADATA statuscontrollercontroller, workers, humans
AssigneeMETADATA assignee (nick)controller on !assigncontroller, listing bot
RequesterMETADATA requestercontroller on !newquota, audit
ConversationChannel message log (CHATHISTORY)everyoneeveryone, search
Final summaryLast message from worker before /part, conventional ## Summary blockworker on donehumans reading log later

Rule: the channel is canonical. If the agent-controller crashes and restarts, it reconstructs state by scanning registered #task-* channels and reading their METADATA. No separate state store.

Bot commands

In #tasks (the meta-channel):

CommandEffect
!new <desc>Create #task-<N>, register it, set status=pending, post desc, invite caller
!assign <N> <nick-or-pool>Spawn worker (if pool) or invite human (if nick); status=in_progress
!tasksList all tasks with status
!tasks mineList tasks where requester or assignee is the caller
!tasks blockedList tasks with status=blocked
!find <text>Search task descriptions and recent history for matching text
!helpList commands

In a #task-N channel:

CommandEffect
!statusShow full METADATA for this task
!killTerminate the worker pod (status stays whatever it was)
!reopenMove done → in_progress, respawn worker if needed
!archiveForce-archive (agent-controller deregisters the channel after a grace period)

Worker-only (sent as PRIVMSG from a worker, parsed by agent-controller):

PatternEffect
[meta] key=valueController updates that METADATA key
[status] <new>Controller transitions status (and updates topic prefix)

The [meta] protocol keeps the worker honest — it never edits its own channel state directly. The agent-controller is the only thing with op on #task-* channels.

agent-worker responsibilities

agent-worker is built on Pi (@earendil-works/pi-agent-core), the agent toolkit already in use elsewhere. Pi provides the agent loop, tool execution, event streaming, and steering/follow-up queues; agent-worker adds the IRC bridge, the per-task lifecycle, and any task-specific tools. See “LLM provider and agent framework” below for why.

On startup:

  1. Connect to ergo as agent-<task-id>.
  2. JOIN #task-<N>.
  3. Read the channel topic (title) and pull the first controller-authored message via CHATHISTORY (description).
  4. Post a one-line “starting” message.
  5. Construct a Pi Agent with the configured model (initially getModel("anthropic", "<claude-model>")) and cwd=/workspace; prompt it with the description.

During work:

  • Subscribe to the agent’s event stream. Forward text_delta events to the channel as PRIVMSGs, chunked at ~800 chars per line.
  • For each tool call, post → ToolName(args) summary at start, and a short ✓ ToolName <duration> (or ✗ ToolName <error>) line at end. These two lines are what the agent-controller picks up to emit tool_call_start / tool_call_end firehose events.
  • For each permission request, post [PERM <id>] <tool>: <preview> and wait for !a <id> / !d <id> from any voiced user in the channel. Permissions are enforced by a custom Pi extension that intercepts tool calls (Pi itself has no permission popups; the IRC approval flow is the permission system).
  • On any state change, post the [status] or [meta] directive to let the agent-controller update the channel and emit the corresponding firehose event.

On clarification needed (incoming human message):

  • The IRC message handler routes the message to agent.steer(text) (if the agent is mid-tool-call) or agent.followUp(text) (if idle). Pi handles the queueing and delivery semantics.
  • Post [status] blocked before requesting clarification; post [status] in_progress when work resumes.

On completion:

  • Post a ## Summary block: what was done, decisions made, open questions. Include any relevant links (PR, gist, etc.) in the summary text.
  • Post [status] done.
  • /part the channel cleanly.

On crash:

  • The k8s Job’s restartPolicy: Never plus backoffLimit: 0 means a crash is terminal.
  • The channel remains, topic still says [in_progress]. A reaper sweep or !status check surfaces this as a stale task to be !reopen’d or !archive’d.

agent-controller responsibilities

Single-process service connected to ergo as controller. Holds channel op on all #task-* channels and on #firehose (via ChanServ founder rights).

  • Subscribe to messages in #tasks and all #task-* channels.
  • Maintain in-memory cache of channel METADATA; rebuild from server on startup by scanning #task-* channels.
  • Spawn agent-worker pods via k8s API on !assign to a pool.
  • Apply [meta] and [status] directives from workers (validate the sender is the assigned worker for that channel).
  • Enforce simple rate limits: max N active workers per requester, max M pods globally.
  • Periodic sweep: tasks in in_progress whose worker pod is gone → flag.
  • Publish structured events to #firehose on every meaningful state change. The controller is the only publisher to that channel (enforced via ChanServ ACL + channel mode +m). See “Observability and dashboards” for the event vocabulary.

The agent-controller has no persistent state of its own. Kill it, restart it, state is reconstructed from IRC.

Agent conversation (memory · steering · linger-for-review)

Status — implemented 2026-06-21, in main, not yet deployed; untested on a real cluster (verify steer/abort and the lingering-pod reap on a live task). Folds in the former plan-agent-conversation note. These are the agent-worker lifecycle changes that let a human converse with an agent about its work, instead of only answering its [[BLOCKED]] questions. The status flow is unchanged (pending → ready → in_progress → for_review → done); the worker just stays reachable and remembers.

The problem it solved

The worker’s loop was “do a turn; if not blocked, submit for review and exit”: it only waited for input on [[BLOCKED]]; a message during the final turn was dropped; after for_review/done there was no process to talk to; and !reopen spawned an amnesiac agent (messages: [] + description). So you could answer questions, but not converse, chat after completion, or talk to an agent that remembered what it did.

Two capabilities

  • A — linger-for-review (synchronous chat): on a non-blocked completion, post for_review but don’t exit — wait, treat further human messages as turns (bouncing for_review ⇄ in_progress), and exit only when reaped or idle.
  • B — resume-with-memory (asynchronous chat): seed the agent with prior channel context on (re)spawn so a reopened task continues with memory.

B is the default (no idle pods; also fixes !reopen rework); A adds live presence for review. They compose: B gives memory, A gives presence.

Worker lifecycle (option A) — loop

“Submit and stay” instead of “submit and die,” plus an idle deadline so a forgotten task can’t pin a pod (sketch — the real file has the subscribe/error plumbing):

const IDLE_EXIT_MS = parseInt(optional("AGENT_IDLE_EXIT_SECONDS", "1800"), 10) * 1000;
function waitForMessageOrTimeout(ms: number): Promise<boolean> {
return new Promise((resolve) => {
const timer = setTimeout(() => { humanResolve = null; resolve(false); }, ms);
humanResolve = () => { clearTimeout(timer); resolve(true); };
});
}
while (turns < maxTurns) {
if (pendingPrompts.length === 0) {
if (!(await waitForMessageOrTimeout(IDLE_EXIT_MS))) break; // idle → exit
continue;
}
// … run the turn …
// KEY CHANGE: submit for review, but DO NOT exit — loop back and wait.
await postStatus(finalText.includes(BLOCK_MARKER) ? "blocked" : "for_review");
}

Two bugs this fixes for free: mid-turn messages are no longer dropped (the loop re-checks pendingPrompts each turn), and for_review is reachable repeatedly.

Memory (option B)

On (re)spawn, seed the agent from history instead of messages: []. As shipped: rather than reconstructing messages[] user/assistant turns, irc-task.ts builds a single context block (buildPriorContext) and prepends it to the first prompt — a respawn gets a fresh nick, so roles can’t be reliably attributed from a flat history; a labeled <nick> text block the model reads as context is more robust. Controller lines, [status]/[meta], tool markers, and [PERM]/!a/!d chatter are filtered out as noise; the prior ## Summary is kept as the agent’s recollection. Capped by AGENT_MEMORY_LINES.

The channel is the agent’s inbox

A task channel is the agent’s durable inbox, drained at whatever granularity the lifecycle allows:

  • Before pickup (pending/ready): no agent yet, so messages accumulate in CHATHISTORYsaved but not delivered. Option B’s spawn-time load delivers them (“notes left before you started”), distinct from the frozen description. So after !ready the description is locked, but the channel stays a briefing inbox.
  • During work (in_progress): injected into the live run (see Steering).
  • At review / after: option A keeps the agent listening; option B lets a !reopen reload the inbox as memory.

Channel narration & tool telemetry

(Commits 9fc7cb2c/6d3fe88b, after the original note.) The task channel now shows the agent’s narration — on each message_end the worker posts the assistant’s text (“thinking out loud”), with a minimal · working… beat that collapses runs of silent tool-only turns. Tool start/end telemetry is no longer in the channel; the worker sends it as a private PRIVMSG to the controller, which mirrors it to #firehose (so tool_call_start keeps its args). [PERM …] stays in-channel (humans approve there). The worker system prompt nudges one short narration line before each tool call. Deploy the controller before the worker — an old controller would ignore the private telemetry and #firehose would lose tool events until updated.

Steering & interruption

@mariozechner/pi-agent-core (v0.73.1) exposes the needed primitives:

PrimitiveSemanticsUse
steer(msg)inject after the current assistant turn finishesredirect — “do X instead”
followUp(msg)run after the agent would otherwise stopdrain the inbox / keep alive
abort()cancel the in-flight run; the bash signal already kills the processstop now — runaway tool

Task-channel UX: bare text → steer (redirect); !stopabort (then park in blocked for new direction); !kill stays the nuclear pod-terminate. The worker ignores other controller !-commands so they aren’t mistaken for steering.

In-process sub-agents (deferred)

Workers can’t spawn pods (automountServiceAccountToken: false), so any sub-agent is a second in-process Agent. The light win is a concierge/triage loop that answers cheap questions or steer()s the main agent (gate it — idle token cost); full delegate(task) sub-agents are deferred (multiplied cost). Default: one agent + steer/followUp.

Controller coordination & resource accounting

With lingering, the controller reaps the pod on terminal transitions: cmdComplete and !archive call spawner.Kill(taskID) (idempotent; !kill already does). Lingering for_review pods hold a pod but don’t count toward MAX_ACTIVE_TOTAL (which counts in_progress) — same approximation as blocked-with-live-pod. Bounds: AGENT_IDLE_EXIT_SECONDS self-reap, reap-on-!complete/!archive, and the vibes-quota (pods: 30) ceiling. If review backlogs starve dispatch, count for_review-with-live-pod toward the cap — deferred until observed.

Config knobs

  • AGENT_IDLE_EXIT_SECONDS (default 1800) — linger self-reap timeout.
  • AGENT_MEMORY_LINES (default 100) — history depth seeded as memory.

Both are injected into the worker by the controller (the PodTemplate’s env is overwritten at spawn), so tune them on the controller deployment, e.g. kubectl -n vibes set env deploy/agent-controller AGENT_IDLE_EXIT_SECONDS=60.

Open questions

  • Memory fidelity vs. cost — how much history to seed; condensed tool summary vs. just conversation + ## Summary.
  • Cap accounting for lingering for_review pods (count, or rely on backstops).
  • maxTurns semantics — does review chat share the work’s turn budget?
  • Does bare text steer or only queue? Auto-steer() is convenient but lets a chatty observer derail work — consider an @agent prefix or requester-only steering; keep !stop privileged.

Live smoke test (untested)

Deploy controller then worker; lower the idle timeout for the run (kubectl -n vibes set env deploy/agent-controller AGENT_IDLE_EXIT_SECONDS=60); join #task-N + #firehose and watch kubectl -n vibes get pods -l app=agent-worker -w.

  1. Linger: !new → note (“use the staging branch”) → !ready. A friendly-nick worker spawns; it works, posts ## Summary + [status] for_review, and the pod stays Running (not Completed).
  2. Memory: the agent’s work reflects the pre-!ready note.
  3. Narration: channel shows short narration lines + · working…, not → bash: spam; #firehose still has tool_call_start with args.
  4. Steer / stop: mid-work, plain text → ↪ noted… + course change; !stop⏹ stopped… + blocked.
  5. Review by conversation: in for_review, ask a question → it answers; request a change → for_review ⇄ in_progress bounce.
  6. Reap: !completedone + task_completed + pod terminates.
  7. Idle reap: leave for_review ~60s → “agent has left”, pod Completed, not swept. Restore AGENT_IDLE_EXIT_SECONDS after. Rollback = redeploy prior images.

Sandboxing

Each agent-worker pod:

  • Own ephemeral PVC mounted at /workspace. Created with the Job, deleted when the Job finishes.
  • automountServiceAccountToken: false — worker has no k8s API access.
  • NetworkPolicy egress allowlist: ergo (port 6667) + DNS + Anthropic API (HTTPS 443). Nothing else.
  • Resource limits: 2 CPU / 2 GiB memory per worker, adjustable.
  • Read-only root filesystem where possible; /workspace is the only writable mount.

The compromise model: a fully-owned worker can talk to ergo and to api.anthropic.com. It cannot scan the cluster, reach internal services, or persist anything outside /workspace.

Observability and dashboards

A separate visualization of “what’s the system doing right now” is built on top of an IRC channel rather than a parallel transport. The agent-controller publishes structured events to a #firehose channel; any subscriber (initial dashboard, future dashboards, ad-hoc CLI tools, an IRC client tailing the channel) can consume them.

Why a firehose channel and not a side bus

  • One system of record. IRC already carries the conversational state; the firehose extends it with structured machine events. No second message bus to deploy, monitor, and reason about.
  • Free replay-on-reconnect via ergo’s CHATHISTORY. The dashboard backend rebuilds its state by replaying the last N hours from #firehose on startup. Same persistence model as the agent-controller itself.
  • Anyone can debug by joining #firehose in any IRC client and watching the raw JSONL go by.

Trust model

The agent-controller is the only publisher to #firehose. Workers emit [meta] and [status] directives in their own task channels; the controller transitions state and then emits the corresponding firehose event. Workers cannot post to #firehose directly (enforced via ChanServ ACL — +o to controller only, channel mode +m so only voiced/op users can speak). This means the firehose’s integrity matches the controller’s, and you don’t have N untrusted publishers forging events about each other.

Event vocabulary

Each #firehose message is a single line of JSON. Field order is not significant; consumers parse by key.

kindFieldsWhen emitted
task_createdtask, title, requesterAfter !new in #tasks, channel and METADATA created
task_assignedtask, assigneeAfter !assign
worker_spawnedtask, worker, pod, modelAfter k8s Job created and worker joined channel
statustask, status, prev_statusOn every status transition
tool_call_starttask, worker, tool, args_previewWhen agent-worker forwards a tool-use event
tool_call_endtask, worker, tool, duration_ms, okWhen the tool completes
permission_requesttask, worker, tool, perm_idWhen agent-worker posts a [PERM ...]
permission_resolvedtask, perm_id, granted, byWhen a human replies !a / !d
heartbeattask, workerEvery ~5s while a worker is active and idle (for animation continuity)
worker_exitedtask, worker, exit_code, reasonWhen k8s Job ends
task_archivedtaskAfter !archive and grace period

Every event also carries t (unix seconds) and id (monotonic per controller process, for ordering). The vocabulary is intentionally versioned: a v field starts at 1 and is bumped on breaking schema changes. Treat the table above as the v1 spec.

Notes on the choice of events:

  • tool_call_start / tool_call_end are two events, not one with a duration, so an animated frontend can play a “working” animation for the in-flight period and react to completion. The duration is included in the end event for grids and timelines.
  • heartbeat exists only for animation smoothness. A status-grid dashboard ignores it; an animated dashboard uses it to keep idle workers visibly alive.

Dashboard architecture

ergo ──IRC──▶ dashboard-backend ──WS──▶ frontend(s)
(joins #firehose,
rebroadcasts as
WebSocket JSON)
  • dashboard-backend: small service (~200 lines), connects to ergo as nick dashboard, joins #firehose, parses JSONL, maintains an in-memory view of current state (tasks, workers, recent events), exposes a WebSocket clients can subscribe to. On client connect it sends a snapshot followed by live events. On its own startup it replays the last N hours from ergo via CHATHISTORY to rebuild state.
  • frontend(s): any client that speaks the WebSocket protocol. The backend doesn’t know or care what renders it; frontends can be swapped or run side-by-side.

Frontend progression

Frontends evolve; the backend and event vocabulary stay stable. Planned progression:

  1. Plain HTML status grid. Table of tasks (id, title, status, assignee, time-in-status, last-activity). List of recent events. Sorts blocked tasks to the top. Built first to validate the event vocabulary before investing in fancier rendering. Throwaway-quality is fine.
  2. Timeline view. Horizontal bars per task showing time in each status; useful for spotting long-blocked work and overall throughput.
  3. Animated workspace (long-term, optional). Workers rendered as entities in a scene, animations bound to events (tool_call_start → walk to the right tool, tool_call_end → return, status=blocked → question-mark indicator, heartbeat → idle wiggle). A natural candidate implementation is a Godot WASM scene consuming the same dashboard WebSocket — Godot’s animation tooling fits the persistent- entity-in-a-scene mental model better than DOM frameworks do. No backend changes are required to add this; the event vocabulary already includes the events animations need (start/end of tool calls, heartbeats, explicit status transitions).

Out-of-scope for the dashboard

  • Editing state from the dashboard (creating tasks, approving permissions, killing workers). All control happens via IRC commands. The dashboard is strictly a viewer. If write-back is later wanted, it should still go through IRC — the dashboard would invoke the same !a / !new commands as a human, not bypass the controller.
  • Per-task logs and full traces. Those belong in standard k8s logs / log aggregation, accessible by pod name. The firehose carries events, not contents.

Client choice and notifications

Any IRC client that speaks RFC 1459 + IRCv3 capabilities can participate. Recommended clients by context:

  • Web / mobile, zero install — The Lounge (self-hosted, PWA-installable, good touch support, server-side message buffer so reconnection is clean).
  • Desktop power-user — irssi, weechat, HexChat, Halloy.
  • Multi-device with persistent session — Quassel (core/client split) or any client behind a ZNC/soju bouncer. ergo’s built-in CHATHISTORY also provides bouncer-like replay to a single client connecting directly.
  • iOS/Android native — Colloquy, Palaver, IRCCloud (managed); coverage varies, test before relying.

Notification rules (configured per-client, not per-server):

  • Highlight on own nick → push / sound / banner.
  • Highlight on [PERM substring → permission requests.
  • Highlight on [status] blocked → clarification needed.

These are conventions the agent-worker emits; clients that support highlight-on-substring (almost all do) can be configured to surface them. No client-specific features are required.

Optional: a push bridge for higher-priority pushes (agent-controller posts on [status] blocked events; phone receives a native push with a deep-link back to the channel). Adds maybe 30 lines to the agent-controller. Useful for users on clients without good push, or as a belt-and-braces alert on top of in-client notifications.

ntfy is a placeholder example throughout this plan, not a chosen tool. The actual requirement is just “one OS-level push when a task goes blocked” (and, by extension, on [PERM ...]). ntfy gets named because it’s the lowest-friction self-hostable fit — a dumb HTTP POST to a private topic, no SDK or auth — but any equivalent satisfies the requirement: Gotify (self-hosted, same shape), or simply reusing the Slack-bot stack already deployed in this namespace (slack-bot-pi-mom.yaml). Decide the transport at build time; don’t read “ntfy” anywhere here as a committed dependency.

Migration from Plane

The shape of the existing system maps cleanly, and most of the migration is renaming and replacing the transport — the k8s scaffolding stays.

Service renames

The existing services are renamed in place to reflect that the system no longer revolves around tickets:

Existing nameNew nameReason
agent-dispatcheragent-controller“Dispatcher” implied one-shot fan-out; the new service owns ongoing channel state, op rights, and lifecycle for every task, which is controller-shaped work.
agent-coderagent-worker“Coder” was too narrow — a worker may do non-coding tasks (ops, research, doc edits). “Worker” matches the per-task ephemeral-pod role.

Mechanical rename touches: Deployment / Service / ServiceAccount / ConfigMap / Secret names, RBAC bindings, image repository paths, env-var references in any sibling services, Helm chart values, monitoring dashboards, logging filters, and any developer docs / runbooks. Update the container image labels at the same time so old and new images are distinguishable in the registry during the transition.

Concept mapping

Plane pieceIRC replacement
Plane webhook → agent-dispatcherIRC !new / !assign in #tasks handled by agent-controller
Plane ticketRegistered channel #task-<N>
Plane ticket descriptionFirst channel message
Plane ticket statusChannel topic prefix + METADATA status
Plane ticket commentsChannel messages
Plane “needs clarification” statusChannel +m mode + METADATA status=blocked
Plane API client inside agent-coderIRC client inside agent-worker (smaller, no auth tokens)
Plane web UI for browsing!tasks bot command + any IRC client’s channel list

Cutover steps

  1. Deploy ergo (and optionally The Lounge) alongside the existing Plane stack. Don’t touch Plane yet.
  2. Fork agent-dispatcheragent-controller: keep the k8s spawning logic, replace the Plane webhook handler with an IRC client connecting to ergo as controller and listening in #tasks. Build and push to a new image tag.
  3. Fork agent-coderagent-worker: keep the Pi agent core logic identical, replace the Plane API client (ticket reads, comment posts, status transitions) with an IRC client (channel reads, PRIVMSGs, [status] directives) and an IRC-bridge Pi extension for permission gating and steer() / followUp() routing. Build and push to a new image tag.
  4. Deploy agent-controller and agent-worker alongside the existing agent-dispatcher / agent-coder. Both systems run in parallel; new tasks can be filed via either Plane or !new in #tasks.
  5. Migrate one workflow at a time. Verify end-to-end (new → assign → clarification → done) in IRC for each.
  6. Stop creating new Plane tickets. Existing tickets either complete in Plane or get re-filed as IRC tasks.
  7. When Plane is drained, scale agent-dispatcher and agent-coder to zero, leave them deployed for one release cycle as rollback insurance, then delete them.
  8. Decommission Plane.

Rollback

For one release after cutover, keep the old agent-dispatcher / agent-coder deployments at replicas=0 with their images and configs intact. Rollback is then kubectl scale --replicas=1 on both, plus re-enabling the Plane webhook. After the holding period, delete.

Data migration

None. Closed Plane tickets stay in Plane for read-only history; if you need long-term archive, export them as JSON before decommissioning. In-flight Plane tickets at cutover time are completed in Plane (don’t half-migrate a live ticket).

Possible integrations

Nice-to-haves that hang off the IRC server without changing the core design. Ranking principle: reuse infra we already run, and respect the trust model — don’t add publishers to #firehose; inbound relays post to their own channels (#git, #alerts, #releases); external-facing bridges run in-cluster as consumers, since ergo is plaintext :6667, in-cluster only. None are committed — this is a menu, not a roadmap.

Highest-leverage (reuse existing infra, close documented gaps):

  • #firehose → Prometheus exporter → Grafana + Alertmanager. A small bridge joins #firehose, parses the frozen v1 event vocab, and exposes /metrics (vibes_tasks_active, vibes_time_in_blocked_seconds, vibes_tool_calls_total, vibes_worker_runtime_seconds). IRC events become first-class metrics in the observability stack, with alert rules like “blocked > 2h” or “worker exceeded JOB_ACTIVE_DEADLINE_SECONDS.” The event stream already exists — this just measures it. Reuses: Prometheus / Grafana / Alertmanager.
  • Forgejo ⇄ IRC relay. The design keeps code review in the forge (“agents open PRs there; IRC tracks the task”), so PR/CI state currently escapes the system of record. A Forgejo webhook → the worker’s #task-N channel (“PR #N opened / CI passed / merged”), and a merge event can drive [status] done. Captures the one signal that today lives outside IRC. Reuses: Forgejo + the levi runner.
  • Channel history → Loki (via Alloy). Ship #firehose + channel logs into Loki and the “free-text search across closed tasks” open question is answered with infra we already operate (LogQL over the archive) — no bespoke indexer. Reuses: Loki / Alloy. (ergo’s CHATHISTORY stays the live store; Loki is the long-term searchable one.)

Client reach / notifications (addresses the dropped :6697 external TLS + weak mobile push):

  • Matrix bridge (Heisenbridge / matrix-appservice-irc) — bridge channels into Matrix so Element gives real mobile push + rich rendering, with IRC still canonical (Matrix is a view). A stronger answer than the ntfy footnote, and it sidesteps external TLS by running in-cluster.
  • Push — ntfy/Gotify on [status] blocked (see “Client choice and notifications”); and/or Alertmanager → #alerts so infra alerts share the IRC surface. soju is the lighter multi-device option if full Matrix is too much.

IRC-native bots (cheap, fully self-hosted):

  • Local-LLM helper on Ollama (logi)?ask <q> in any channel and, more useful, !summarize to TL;DR a long #task-N log on demand. No Anthropic spend, no worker pod spawned. Reuses: the GPU host.
  • Garage-backed paste service + bot — makes the “link to pastes, don’t inline” non-goal real: upload long worker output to a Garage-backed pastebin, post a short link. Reuses: Garage (idle since Plane’s removal — see netpol.yaml).
  • Digest / watcher bots — periodic open/blocked summary to #tasks; pipe the existing upstream/release watcher (upstream-watch.md, plan-release-watcher-on-control-server.md) and restic-backup status into #releases / #ops. Makes IRC the single pane for “what changed.”

Identity (ties to the “Declarative bot/channel seeding” open question): point ergo’s oauth2 / jwt-auth / auth-script hooks at the IdP/OpenBao, or use SASL EXTERNAL via step-ca certfp, so every bot above authenticates from the existing identity system rather than a hand-registered account.

If we build three: the firehose→metrics exporter and history→Loki (both reuse observability and resolve open questions already written down) plus the Forgejo bridge (captures PR/CI state, the one signal that escapes the system of record).

Repo-scoped tasks (implemented — not yet deployed)

Status: built 2026-06-21; awaiting CI/CD + deploy. A way to organize tasks by Git repo and give each task its repo context up front — instead of the worker guessing the repo (in the first live smoke test the agent went hunting and pushed to chnm/popquiz on its own). The valuable core is the repo binding; the channel layout is secondary.

What landed (controller + worker, all unit-tested):

  • Repo hubs with channel seeding. !hub <repo> [git-url] in #tasks creates a #<repo> hub: the controller JOINs, ChanServ-REGISTERs, stamps a [repo] topic + repo/repo_url/default_branch METADATA (the source of truth), and invites the requester. repo_url derives from GIT_BASE_URL + the bare name (overridable). Idempotent; reserved/invalid names rejected.
  • Reconstruction with no new store. On LIST, a [repo]-topic channel is rebuilt as a hub (JOIN + METADATA probe); a #<repo>-task-<N> re-registers its hub by prefix. Hub repo_url/default_branch + task repo/repo_url/ default_branch are read back on reconnect.
  • Declarative startup seeding (resolves the “declarative ergo seeding” open item). SEED_REPOS (<name> or <name>=<url>, comma/space separated) is provisioned on connect — the hub analogue of joining #firehose/#tasks. It fires on RPL_LISTEND, after reconstruction, and only creates hubs still missing, so it never re-registers an existing hub or clobbers an operator’s later !repo edit on restart. Reserved/invalid names are skipped.
  • Repo-bound tasks. !new in a hub files #<repo>-task-<N>, inheriting the hub’s URL/branch (stamped on the task channel + mirrored as a one-line index entry to #tasks). !repo inside a #task-<N> binds a repo-less draft (no rename; binding rides METADATA + env). !repo url|branch on a hub edits the source of truth and re-stamps the repo’s not-yet-started tasks.
  • Worker wiring. Spawn injects TASK_REPO/TASK_REPO_URL/TASK_BRANCH; the worker clones + checks out the base branch up front and works in the checkout (if the clone fails the prompt says so and tells it to clone itself, rather than pointing it at an empty dir). Repo-less tasks are unchanged (no repo env, flat #task-<N>).
  • Git workflow policy (bound tasks): never commit/push to main/default; create a conventionally-prefixed feature branch (feat/…, fix/…, chore/…, …), commit directly to it, branch off TASK_BRANCH unless the human names a different base; at review time push the branch, open a PR (gh pr create), and put the PR URL in the ## Summary.
  • Firehose carries a repo field on every task-scoped event (one stream).
  • !tasks [...] [repo:<name>] filter; a hub’s own !tasks is repo-scoped.

Remaining: push + CI/CD mirror, set GIT_BASE_URL (and optionally SEED_REPOS) on the controller, deploy controller-then-worker, live smoke test; clean up the earlier stray chnm/popquiz push.

Reframe: namespace per repo, NOT a network per repo

An IRC network is a server (ergo = one network). “A network per repo” would mean N ergo instances — each needing its own #tasks + #firehose, the controller connecting to all N, the dashboard subscribing to all N, accounts/ACLs multiplied. That’s an organization problem dressed as an infra one. Stay on one network and use a per-repo channel namespace; per-repo access isolation, if needed, comes from channel ACLs (invite-only hub + ChanServ access), not separate servers. Reserve true multi-network for hard multi-tenant isolation (not our case).

The core: bind a task to a repo

Make repo an optional, late-bindable dimension of a task:

  • Task gains repo (owner/name), repo_url, default_branch, stored as channel METADATA and read back on reconnect (like other state).
  • The controller injects them into the worker (TASK_REPO, TASK_REPO_URL, TASK_BRANCH) so it clones/cds into the right checkout — no guessing.
  • Commands: !new <desc> in a repo hub auto-binds that repo (the channel implies it); !repo <owner/name> binds/rebinds later — covering the “agent just created a new repo, attach it” case and multi-repo tasks (bind the primary, note the rest in the description).
  • Binding is guidance, not a sandbox. The worker still carries broad gh creds, so a bound task can still reach other repos. Hard scoping is a separate creds/egress change (per-task scoped tokens), out of scope here.

Channel layout (one network)

  • Per-repo hub channel #<repo> (e.g. #popquiz; bare repo name — single-org workspace, so it’s unambiguous; use -, not /, for ergo-safe names). Holds repo-level METADATA; the controller posts that repo’s task lifecycle here; !new here auto-binds the repo. The controller idempotently ensures/registers the hub on first use (ties into the “declarative ergo seeding” open question). (The full owner/name still lives in METADATA; only add the owner to the channel name if you ever host same-named repos under different owners.)
  • Per-task channel #<repo>-task-<N>, inheriting the hub’s repo METADATA → the worker gets context automatically.
  • Repo-less tasks stay in the global #tasks as #task-<N> (no repo METADATA), bindable later via !repo. Repo is a dimension, not a requirement — research, ops, multi-repo, and “create a new repo” tasks all start unbound.

Firehose & control stay single

  • One #firehose, not one per repo — keep a single stream/replay/dashboard subscription; just add a repo field to every event and let consumers filter. Splitting it per repo fragments observability for no gain.
  • One global #tasks for repo-less and cross-repo control + spawning new hubs; per-repo hubs are an additional grouping surface for humans, not a replacement.
  • !tasks repo:<name> filter; the board/console group by repo.

Concrete deltas (if built)

  • Task{ Repo, RepoURL, Branch }; METADATA keys repo / repo_url / default_branch + HandleMetadata read-back.
  • cmdNew infers repo from the hub channel; add cmdRepo (!repo); workerEnv injects TASK_REPO/TASK_REPO_URL/TASK_BRANCH.
  • Worker: prefer TASK_REPO (clone/cd) over discovery; drop the “find a repo I can push to” behavior.
  • Firehose events gain repo; task_created carries it; !tasks repo:<name>.
  • Hub-channel ensure/register on first repo use.

Open sub-questions

  • Hub identity/permissions — who can create a hub / !new in it; per-repo ACLs vs. open.
  • Multi-repo tasks — single primary binding + freeform, or a list?
  • Repo metadata source — controller fetches default_branch via gh, or the human supplies it on bind?
  • Naming/casefold — ergo channel charset + length for #<repo>-task-N; and the #<repo> hub vs. an existing same-named channel (collision within the org).

Open questions

  • Search. Free-text search across closed tasks needs either ergo’s history search (limited) or a sidecar indexer. Defer until needed.
  • Dependencies. Some tasks block others. Convention: worker posts blocked_by=#task-N in METADATA, listing bot shows the graph. No enforcement.
  • Multi-human collaboration. Two humans in the same task channel works naturally — IRC was built for this. Worker treats any human message as potential clarification input; if both humans contradict, worker asks for resolution.
  • Cost attribution. Per-pod Anthropic API spend. Either tag pods and use Anthropic’s per-key spend tracking, or have the worker emit [meta] cost=<usd> on completion.
  • Declarative bot/channel seeding (IaC). ergo’s durable identity state — accounts, registered channels, channel ACLs, METADATA — lives in BoltDB (ircd.db), i.e. runtime state, not the ConfigMap. The ConfigMap is IaC for server behavior (listeners, limits, opers, capabilities) but not the inventory of accounts/channels. So one step is un-IaC’d: the controller ergo account is created imperatively today — a one-time NickServ REGISTER, or a one-shot IRC_REGISTER_ACCOUNT=true boot (see agent-controller.yaml). Scope is small: only controller needs an account — dashboard and the agent-<N> workers connect anonymously (require-sasl is off), so the surface is one identity + the two standing channels (#tasks, #firehose with +m + sole-publisher ACL). Options, increasing IaC-purity: (1) self-register on first connect — already supported; the ESO Secret is the source of truth, account is a materialization; idempotent if “exists” = success, but doesn’t cover channels. (2) idempotent ergo-seed Job — a Kustomize-resource Job run in make k0s after ergo is ready that opers up from OpenBao/ESO, registers controller, and registers + configures #tasks/#firehose; matches the OpenBao-bootstrap / restic-seed “declare once, create-only reconcile” pattern already in the repo. (3) externalize identity — ergo already exposes auth-script, jwt-auth, and oauth2 hooks (all enabled: false, all autocreate: true); point one at OpenBao / the IdP (kanidm) and there’s no account to seed — BoltDB becomes a cache. Most IaC-pure; also the prerequisite for safely enabling require-sasl. (4) channels as a controller reconcile-loop — the controller already owns #task-*; have it also ensure #tasks/#firehose exist + configured on every startup, so channels need no separate seed artifact. Anti-pattern: pre-baking ircd.db / ergo importdb (couples to DB schema, only seeds an empty DB, opaque source of truth). Lean: (2)+(4) near-term, (3) long-term. Wrinkle — ephemeral identities can’t be IaC’d. Workers are minted at spawn and connect anonymously, which is fine while require-sasl is off. If it’s ever enabled (note force-nick-equals-account: true is already set), every worker needs an identity; the clean answer is SASL EXTERNAL + certfp — a short-lived step-ca client cert per worker pod, authorized by fingerprint, with the account⇄fingerprint binding minted by the controller at spawn (runtime, not IaC).

Implementation order

  1. Deploy ergo. Connect with any IRC client, manually create a channel, talk to yourself, confirm CHATHISTORY and METADATA work.
  2. (Optional) Deploy The Lounge for browser/PWA access. Skip if everyone on the team has a preferred client already.
  3. Fork agent-dispatcheragent-controller. Implement !new and !tasks first. No worker spawning yet — just channel creation and METADATA.
  4. Fork agent-coderagent-worker. Swap Plane API client for IRC client; keep the Pi agent core logic identical. Add the IRC-bridge extension that handles permission gating and translates IRC messages into steer() / followUp() calls.
  5. Wire !assign in agent-controller to spawn agent-worker pods. Test end-to-end on a trivial task.
  6. Add the clarification flow ([status] blocked, worker forwards human replies via steer() / followUp(), resumes).
  7. Add !kill, !reopen, !archive. Add the reaper sweep in agent-controller.
  8. Add #firehose publishing to agent-controller. Frozen v1 event vocabulary. Verify by tail-ing the channel from any IRC client and running a few tasks through.
  9. Build the dashboard backend (~200 lines): joins #firehose, maintains in-memory state, exposes a WebSocket with snapshot + live events. On startup, replays CHATHISTORY to rebuild state.
  10. Build a deliberately plain HTML status-grid frontend against the WebSocket. Throwaway quality. Goal is to validate the event vocabulary in production use before investing in fancier rendering.
  11. Document recommended notification rules for common IRC clients. Optional: a push bridge for [status] blocked (ntfy is a placeholder example — Gotify or the existing Slack-bot stack work too).
  12. Run alongside Plane for one release. If it holds up, drain Plane, scale down the old services for the rollback holding period, then decommission.
  13. (Optional, later) Iterate on the dashboard frontend — timeline view, then an animated workspace (e.g., Godot WASM). No changes to backend or event vocabulary required.