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:
| # | Step | State | Evidence |
|---|---|---|---|
| 1 | Deploy ergo | ✅ done | k0s/vibes/ergo.yaml |
| 2 | Deploy The Lounge | ✅ done | k0s/vibes/thelounge.yaml |
| 3 | agent-controller (!new/!tasks, METADATA) | ✅ done | vibes/irc/agent-controller/, commit 638f119c |
| 4 | agent-worker (IRC bridge + Pi core) | ✅ done | vibes/irc/agent-worker/, agent-worker-template.yaml |
| 5 | !assign spawns worker | ✅ done | a8f58ded |
| 6 | clarification flow (blocked / steer / followUp) | ✅ done | part of 638f119c |
| 7 | !kill/!reopen/!archive + reaper sweep | ✅ done | stale-worker sweep a8f58ded, pvc-reaper 5c2f3a69 |
| 8 | #firehose v1 publishing | ✅ done | e50829e2 |
| 9 | dashboard backend (SSE — plan said WebSocket) | ✅ done | vibes/irc/dashboard-backend/, dashboard.yaml, 81c348f0 |
| 10 | plain HTML status-grid frontend | ✅ done | 81c348f0 |
| 11 | notification-rule docs / push bridge | ⚠️ partial | push bridge not built; ntfy is a placeholder example, not a chosen tool (optional) |
| 12 | run alongside Plane, then decommission | ↪️ diverged — see below | 638f119c, 389fc467 |
| 13 | timeline / 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=0for a rollback window, then deleting. In practice Plane CE was removed and the stack retired outright (389fc467removed Plane CE;638f119cmigrated 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 arevibes/irc/<name>/. agent-controller and dashboard-backend deploy:main; agent-worker deploys:latest(mutable, re-checked per pull — see the note inagent-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.yamlserves the status grid over Server-Sent Events on:8080, and the backend joins#firehoseanonymously (no IRC account or secret — ergo’s:6667allows unauthenticated reads), not as a logged-indashboardnick. 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 iskubectl port-forwardtoday; 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 ofworldon 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, rungit/gh, fetch docs, and call third-party APIs — an Anthropic-only egress was impractical. The real guardrails remain the pod boundary,automountServiceAccountToken: false, andworldexcluding 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 PVCdelete, and the Job TTL reclaims the Job but not the PVC — so a separate dailyagent-pvc-reaperCronJob (its own SA withdelete, reusing the controller image asagent-controller reaper) reaps idle orphans, DRY-RUN by default today. Why: keeping the destructivedeleteprivilege off the long-running controller bounds its blast radius; the cost is one extra GC component (commit5c2f3a69). - Worker root filesystem is writable. Sandboxing says “read-only root
filesystem where possible.” The worker runs
readOnlyRootFilesystem: falsewith a/workspacePVC plus a/tmpemptyDir. Why: the agent shells out tobash/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
:6667plaintext asergo.vibes.svc.cluster.local; the:6697TLS listener is dropped and STS disabled. The only ways in today are The Lounge (web, behind its own Ingress/TLS) orkubectl 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 existingMAX_ACTIVE_PER_REQUESTER=5and the 1-hourJOB_ACTIVE_DEADLINE_SECONDS. The namespacevibes-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 accountcontroller) andagent-worker(ANTHROPIC_API_KEY,GIT_TOKEN) — and theVIBES_env prefix was dropped (e9a26155). The assignment-pool default model is pinned toWORKER_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(plusblocked/archived).!newcreates 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 toMAX_ACTIVE_TOTAL, refilling a slot whenever one frees.!assignsurvives only as an optional override to hand a task to a specific human. - Review gate. The worker submits
[status] for_review(notdone) 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).!tasksnow accepts any status as a filter. The worker reads the description from aTASK_DESCRIPTIONenv 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 theagent-nick prefix onto a persistedspawnedMETADATA flag. - METADATA gained
description,spawned,job, read-back ofcreated(so task age / dispatch order survive a reconnect), and write-only<status>_atcycle-time markers. - Firehose gained
task_ready,task_completed,task_updated;tool_call_startnow carries the command inargs. Corrections vs the v1 table: there is noheartbeatevent (a worker re-emits[status] in_progresseach turn → astatusevent withprev_status == in_progress);tool_call_endcarries noduration_ms;worker_exitedcarriesreason(e.g.vanished), notexit_code; thetool_call_startfield isargs, notargs_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).#firehoseintegrity 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 publictasks.rrchnm.internalHTTPRoute; 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 theCONTROLLER_NICKinjection fix. Untested on a live cluster — see the “Agent conversation” section below. - Repo-scoped tasks — built 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 workerThe 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:
- ergo — IRCv3 server (history, METADATA, CHATHISTORY, registered channels). One pod, one PVC, BoltDB on disk.
- 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.
- 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 existingagent-dispatcherservice.) - 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-coderservice.) - dashboard-backend (added in a later phase) — joins
#firehose, maintains in-memory state, exposes a WebSocket to frontends. - 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_deltaevents 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, keyapi-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_progressStatus 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
| Concept | Storage | Set by | Read by |
|---|---|---|---|
| Task ID | Channel name #task-<N> | controller on !new | everyone |
| Title | Channel topic (after status prefix) | controller, worker | everyone |
| Description | First message in channel, by controller | controller on !new | worker on join |
| Status | Topic prefix + METADATA status | controller | controller, workers, humans |
| Assignee | METADATA assignee (nick) | controller on !assign | controller, listing bot |
| Requester | METADATA requester | controller on !new | quota, audit |
| Conversation | Channel message log (CHATHISTORY) | everyone | everyone, search |
| Final summary | Last message from worker before /part, conventional ## Summary block | worker on done | humans 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):
| Command | Effect |
|---|---|
!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 |
!tasks | List all tasks with status |
!tasks mine | List tasks where requester or assignee is the caller |
!tasks blocked | List tasks with status=blocked |
!find <text> | Search task descriptions and recent history for matching text |
!help | List commands |
In a #task-N channel:
| Command | Effect |
|---|---|
!status | Show full METADATA for this task |
!kill | Terminate the worker pod (status stays whatever it was) |
!reopen | Move done → in_progress, respawn worker if needed |
!archive | Force-archive (agent-controller deregisters the channel after a grace period) |
Worker-only (sent as PRIVMSG from a worker, parsed by agent-controller):
| Pattern | Effect |
|---|---|
[meta] key=value | Controller 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:
- Connect to ergo as
agent-<task-id>. JOIN #task-<N>.- Read the channel topic (title) and pull the first
controller-authored message viaCHATHISTORY(description). - Post a one-line “starting” message.
- Construct a Pi
Agentwith the configured model (initiallygetModel("anthropic", "<claude-model>")) andcwd=/workspace; prompt it with the description.
During work:
- Subscribe to the agent’s event stream. Forward
text_deltaevents to the channel asPRIVMSGs, 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 emittool_call_start/tool_call_endfirehose 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) oragent.followUp(text)(if idle). Pi handles the queueing and delivery semantics. - Post
[status] blockedbefore requesting clarification; post[status] in_progresswhen work resumes.
On completion:
- Post a
## Summaryblock: what was done, decisions made, open questions. Include any relevant links (PR, gist, etc.) in the summary text. - Post
[status] done. /partthe channel cleanly.
On crash:
- The k8s Job’s
restartPolicy: NeverplusbackoffLimit: 0means a crash is terminal. - The channel remains, topic still says
[in_progress]. A reaper sweep or!statuscheck 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
#tasksand 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
!assignto 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_progresswhose worker pod is gone → flag. - Publish structured events to
#firehoseon 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_reviewbut don’t exit — wait, treat further human messages as turns (bouncingfor_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 inCHATHISTORY— saved but not delivered. Option B’s spawn-time load delivers them (“notes left before you started”), distinct from the frozen description. So after!readythe 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
!reopenreload 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:
| Primitive | Semantics | Use |
|---|---|---|
steer(msg) | inject after the current assistant turn finishes | redirect — “do X instead” |
followUp(msg) | run after the agent would otherwise stop | drain the inbox / keep alive |
abort() | cancel the in-flight run; the bash signal already kills the process | stop now — runaway tool |
Task-channel UX: bare text → steer (redirect); !stop → abort (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_reviewpods (count, or rely on backstops). maxTurnssemantics — 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@agentprefix or requester-only steering; keep!stopprivileged.
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.
- 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). - Memory: the agent’s work reflects the pre-
!readynote. - Narration: channel shows short narration lines +
· working…, not→ bash:spam;#firehosestill hastool_call_startwithargs. - Steer / stop: mid-work, plain text →
↪ noted…+ course change;!stop→⏹ stopped…+blocked. - Review by conversation: in
for_review, ask a question → it answers; request a change →for_review ⇄ in_progressbounce. - Reap:
!complete→done+task_completed+ pod terminates. - Idle reap: leave
for_review~60s → “agent has left”, podCompleted, not swept. RestoreAGENT_IDLE_EXIT_SECONDSafter. 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;
/workspaceis 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
#firehoseon startup. Same persistence model as the agent-controller itself. - Anyone can debug by joining
#firehosein 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.
kind | Fields | When emitted |
|---|---|---|
task_created | task, title, requester | After !new in #tasks, channel and METADATA created |
task_assigned | task, assignee | After !assign |
worker_spawned | task, worker, pod, model | After k8s Job created and worker joined channel |
status | task, status, prev_status | On every status transition |
tool_call_start | task, worker, tool, args_preview | When agent-worker forwards a tool-use event |
tool_call_end | task, worker, tool, duration_ms, ok | When the tool completes |
permission_request | task, worker, tool, perm_id | When agent-worker posts a [PERM ...] |
permission_resolved | task, perm_id, granted, by | When a human replies !a / !d |
heartbeat | task, worker | Every ~5s while a worker is active and idle (for animation continuity) |
worker_exited | task, worker, exit_code, reason | When k8s Job ends |
task_archived | task | After !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_endare 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.heartbeatexists 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:
- 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.
- Timeline view. Horizontal bars per task showing time in each status; useful for spotting long-blocked work and overall throughput.
- 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/!newcommands 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
[PERMsubstring → 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 name | New name | Reason |
|---|---|---|
agent-dispatcher | agent-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-coder | agent-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 piece | IRC replacement |
|---|---|
| Plane webhook → agent-dispatcher | IRC !new / !assign in #tasks handled by agent-controller |
| Plane ticket | Registered channel #task-<N> |
| Plane ticket description | First channel message |
| Plane ticket status | Channel topic prefix + METADATA status |
| Plane ticket comments | Channel messages |
| Plane “needs clarification” status | Channel +m mode + METADATA status=blocked |
| Plane API client inside agent-coder | IRC client inside agent-worker (smaller, no auth tokens) |
| Plane web UI for browsing | !tasks bot command + any IRC client’s channel list |
Cutover steps
- Deploy ergo (and optionally The Lounge) alongside the existing Plane stack. Don’t touch Plane yet.
- Fork
agent-dispatcher→agent-controller: keep the k8s spawning logic, replace the Plane webhook handler with an IRC client connecting to ergo ascontrollerand listening in#tasks. Build and push to a new image tag. - Fork
agent-coder→agent-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 andsteer()/followUp()routing. Build and push to a new image tag. - Deploy
agent-controllerandagent-workeralongside the existingagent-dispatcher/agent-coder. Both systems run in parallel; new tasks can be filed via either Plane or!newin#tasks. - Migrate one workflow at a time. Verify end-to-end (new → assign → clarification → done) in IRC for each.
- Stop creating new Plane tickets. Existing tickets either complete in Plane or get re-filed as IRC tasks.
- When Plane is drained, scale
agent-dispatcherandagent-coderto zero, leave them deployed for one release cycle as rollback insurance, then delete them. - 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 exceededJOB_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-Nchannel (“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 + thelevirunner. - 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 →#alertsso 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,!summarizeto TL;DR a long#task-Nlog 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#taskscreates a#<repo>hub: the controller JOINs, ChanServ-REGISTERs, stamps a[repo]topic +repo/repo_url/default_branchMETADATA (the source of truth), and invites the requester.repo_urlderives fromGIT_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. Hubrepo_url/default_branch+ taskrepo/repo_url/default_branchare 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 onRPL_LISTEND, after reconstruction, and only creates hubs still missing, so it never re-registers an existing hub or clobbers an operator’s later!repoedit on restart. Reserved/invalid names are skipped. - Repo-bound tasks.
!newin 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).!repoinside a#task-<N>binds a repo-less draft (no rename; binding rides METADATA + env).!repo url|branchon 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 offTASK_BRANCHunless 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
repofield on every task-scoped event (one stream). !tasks [...] [repo:<name>]filter; a hub’s own!tasksis 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
ghcreds, 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;!newhere auto-binds the repo. The controller idempotently ensures/registers the hub on first use (ties into the “declarative ergo seeding” open question). (The fullowner/namestill 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
#tasksas#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 arepofield to every event and let consumers filter. Splitting it per repo fragments observability for no gain. - One global
#tasksfor 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 byrepo.
Concrete deltas (if built)
Task{ Repo, RepoURL, Branch }; METADATA keysrepo/repo_url/default_branch+HandleMetadataread-back.cmdNewinfers repo from the hub channel; addcmdRepo(!repo);workerEnvinjectsTASK_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_createdcarries it;!tasks repo:<name>. - Hub-channel ensure/register on first repo use.
Open sub-questions
- Hub identity/permissions — who can create a hub /
!newin it; per-repo ACLs vs. open. - Multi-repo tasks — single primary binding + freeform, or a list?
- Repo metadata source — controller fetches
default_branchviagh, 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-Nin 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: thecontrollerergo account is created imperatively today — a one-timeNickServ REGISTER, or a one-shotIRC_REGISTER_ACCOUNT=trueboot (seeagent-controller.yaml). Scope is small: onlycontrollerneeds an account —dashboardand theagent-<N>workers connect anonymously (require-saslis off), so the surface is one identity + the two standing channels (#tasks,#firehosewith+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) idempotentergo-seedJob — a Kustomize-resource Job run inmake k0safter ergo is ready that opers up from OpenBao/ESO, registerscontroller, 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 exposesauth-script,jwt-auth, andoauth2hooks (allenabled: false, allautocreate: 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 enablingrequire-sasl. (4) channels as a controller reconcile-loop — the controller already owns#task-*; have it also ensure#tasks/#firehoseexist + configured on every startup, so channels need no separate seed artifact. Anti-pattern: pre-bakingircd.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 whilerequire-saslis off. If it’s ever enabled (noteforce-nick-equals-account: trueis 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
- Deploy ergo. Connect with any IRC client, manually create a channel, talk to yourself, confirm CHATHISTORY and METADATA work.
- (Optional) Deploy The Lounge for browser/PWA access. Skip if everyone on the team has a preferred client already.
- Fork
agent-dispatcher→agent-controller. Implement!newand!tasksfirst. No worker spawning yet — just channel creation and METADATA. - Fork
agent-coder→agent-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 intosteer()/followUp()calls. - Wire
!assignin agent-controller to spawn agent-worker pods. Test end-to-end on a trivial task. - Add the clarification flow (
[status] blocked, worker forwards human replies viasteer()/followUp(), resumes). - Add
!kill,!reopen,!archive. Add the reaper sweep in agent-controller. - Add
#firehosepublishing to agent-controller. Frozen v1 event vocabulary. Verify bytail-ing the channel from any IRC client and running a few tasks through. - 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. - 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.
- Document recommended notification rules for common IRC clients.
Optional: a push bridge for
[status] blocked(ntfyis a placeholder example — Gotify or the existing Slack-bot stack work too). - Run alongside Plane for one release. If it holds up, drain Plane, scale down the old services for the rollback holding period, then decommission.
- (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.