Skip to content

Agent dispatch unification

Status (2026-08-29): Phases 0, 0.5 and 1.5 are deployed and verified live — the controller is now a headless dispatcher (typed command API + SSE event stream on :9101) and IRC runs as the separate clients-irc adapter (store-authoritative flip 2026-08-24, IRC-ectomy cutover 2026-08-26, both merged to main; adapter 1/1, stable). Phase 1’s per-task worker shape was verified through the new API. Phases 3–5 (the Slack-facing work) remain — Phase 2 is reframed as the controller’s own native interface, largely already built as that command API. Consolidates the two parallel agent-orchestration stacks (IRC and Slack) onto the one that already has the right shape, and unpins the worker runtime from Anthropic so “provider-agnostic” is true in fact rather than in intent.

Shipped and proven. Provider unpin ran end to end on both OpenRouter and Anthropic (real PRs); the store mirrors IRC (drift-clean) and typed :9100 ingest is the sole worker→controller path. Getting there cleared two deploy blockers not in the original plan — a vibes/-namespace image-name mismatch that silently defeated auto-pin, and an uppercase-task-id bug that made every pvc-task-<id> an invalid K8s name (so no worker had ever actually spawned). Remaining in 0.5: flip AGENT_STORE_AUTHORITATIVE — gated now on a tested .db restic restore (the repair subcommand was dropped; see the decision log), not more code.

One open blocker before Phase 4 can be designed: whether pi-mom’s Socket Mode client surfaces block_actions. It cannot be answered from source — see Open questions for the one-line check against the running pod. Phases 0–3 are unblocked.

Phase 0.5 (added 2026-08-23) gates Phases 2–5. A source review found that “the channel is canonical” — the record model every later phase adds writers and volume to — does not hold: registered task channels accumulate against a 15-channel cap with no error path, history expires in a week, and the firehose both derives from chat prose and silently drops its own oversized events. The controller gets a real state store and IRC becomes a projection. See Phase 0.5 for the evidence and the change.

Sequencing revised 2026-08-23 on the finding that the IRC stack has no users (zero task channels in ergo). IRC stays a supported client option, but the original order was built to protect a running system that isn’t running. Phase 0.5’s dual-write evidence period is dropped, a new Phase 1.5 decouples IRC from the controller while that is still cheap, and Phase 2’s proxy design is superseded by it. Numbers are kept, so they no longer read in execution order — see the table at the top of Sequencing.

Depends on: irc-ai-agents steps 3–10 (controller, worker, firehose, dashboard) — all shipped. The base/agent-wrapper bump to pi-mono 0.73.1, already executed per vibes/irc/pi-mono-update-plan.md.

Blocks: simplifies plan-agent-workspace-renderer.md Phase 4 substantially — if Slack work becomes controller-dispatched work, the firehose already carries it and no wrapper emitter or backend ingest path is needed.

Background: irc-ai-agents (the IRC stack’s design of record); plan-agent-task-console.md (specs the write-proxy and flags the attribution problem this plan inherits); slack/AGENTS.md + slack/docs/architecture.md; comparison of the munder-difflin, pi-mom and agent-controller orchestrators, 2026-08-22. turnstone/channels/slack/ is the working precedent for every interactive mechanic this plan needs — see “Slack can carry the full workflow” below.


Motivation

We run two orchestration stacks for the same job, and only one of them is built the way we’d choose on purpose.

The IRC stack already has the layering we want: a deterministic Go dispatcher (agent-controller) that owns scheduling, state, and the only K8s credentials in the namespace; a TypeScript pi-mono harness (base/agent-wrapper) that does the work inside an ephemeral per-task pod; and ergo as the durable record. No LLM sits in the dispatch decision, and the whole thing is unit-tested.

The Slack stack duplicates all of that badly. pi-mom is router and dispatcher and Slack-token holder: its forward_* tools spawn a detached worker.py that POSTs to a long-lived agent Deployment. The consequences are concrete:

  • No record. A forward returns “Request forwarded…” and pi-mom never learns whether it succeeded. There is no ledger, no status, no completion signal — half the fleet is invisible to every dashboard we have.
  • State dies on restart. Agent conversation state is per-channel and in-memory in the wrapper (base/agent-wrapper/src/index.ts), so a rollout loses a multi-turn task mid-flight. slack/docs/architecture.md says as much.
  • Concurrent threads collide. That in-memory state is keyed by channel_id, so two Slack threads in one channel share a conversation.
  • Two of everything. Two authz models (pi-mom’s deny-by-default capability ACL vs. the controller’s none), two attribution schemes, two dispatch paths, two worker lifecycles.

Meanwhile the IRC stack has its own defect that cuts directly against the direction we want to go: the worker runtime is Anthropic-only by construction. internal/bot/models.go hard-codes a three-entry Claude menu, spawn.go:248 sets LLM_PROVIDER to the literal "anthropic", and ANTHROPIC_API_KEY is a required secret ref. Routing more work onto that runtime today would migrate it onto a narrower LLM surface than it is leaving.


Current state

The two stacks

IRCSlack
Dispatcheragent-controller (Go) — scheduling, state machine, k8spi-mom (patch-tools.js → detached worker.py)
Routing decisiondeterministic: oldest ready first, under MaxActiveTotal (default 3)an LLM picks a forward_* tool from a closed list
Workerephemeral Job + PVC per task; no cluster credentialslong-lived Deployment per capability; in-memory per-channel state
Harnessbase/agent-wrapper (TypeScript, pi-mono)same image, same packages
Recordergo: topic + METADATA + CHATHISTORY, plus #firehose{channel}/log.jsonl on a PVC. No task state
Authznone — any IRC user can !new/etc/pi-mom/acl, deny-by-default, capability → global|channel:<id>
Completion signalnotifyRequester() DMs on for_review/blocked/done (bot.go:376)none

The Go/TypeScript boundary already exists — on the IRC side

ergo (record) ← agent-controller (Go: dispatch, state, k8s) → agent-worker pod
└─ base/agent-wrapper
(TypeScript, pi-mono)

This plan does not invent that split. It makes Slack a client of it.

Facts that constrain the design

  • main.go: “it connects out to ergo (no inbound listener).” The controller has no HTTP surface at all today, by stated design.
  • The controller is the only component in vibes with K8s API access. Workers carry no cluster credentials. Its blast radius is the whole namespace.
  • bot.New(cfg, conn, spawner) is rebuilt on every reconnect (main.go loop); state is rebuilt from IRC each time. Anything long-lived must not hold a *Bot.
  • agent-controller reaper is already a subcommand run as a separate workload with its own RBAC, precisely because the controller SA lacks PVC delete. The pattern for “same codebase, different privileges, separate process” is established.
  • WORKER_POD_TEMPLATE is controller-level config (spawn.go:130) — one pod shape for every task.
  • GH_TOKEN/GIT_TOKEN come from one shared AgentSecretName (spawn.go:255).
  • bot.go:630 sets Requester = sender, so anything filed by a proxy identity reads requester = <proxy>. This is the open question plan-agent-task-console.md already flagged, with a proposed trusted [as <nick>] prefix.

Proposed target state

Three layers, and one invariant

LayerLanguageOwnsLLM?
Clients — Slack app, relay-client, The Lounge, gamjaanypresentationno
Dispatcheragent-controllerGoscheduling, state machine, the task store, k8s API, IRC writes, #firehosenever
Harnessbase/agent-wrapperTypeScript (pi-mono)doing the work; provider choiceyes

Invariant: nothing that talks to an LLM holds cluster credentials or owns state; nothing that owns state talks to an LLM.

Already true on the IRC side. Violated on the Slack side today, where pi-mom routes, dispatches, and holds the Slack token.

pi-mom becomes a translator, not an orchestrator

Natural-language intent routing is a client concern. IRC users type !new — explicit, no model needed. Slack users type English, so that client needs an LLM to turn English into a typed request. That is pi-mom’s entire remaining job:

Slack message → pi-mom (LLM) → { capability, message, requester, origin, thread }
→ dispatcher → result → pi-mom relays to the thread

It keeps its deny-by-default ACL (a client-side authz check is fine and it is the only authz we have), its typed closed-list forward_* tools, and channel-tail injection. It loses worker.py, the detached spawn, and any ownership of work.

LLMs end up only at the edges — one in the Slack translator, one in each worker — and the deterministic middle has none.

The dispatch API — a separate process, same codebase

The controller gains a typed API, but not on the k8s-privileged process. Following the existing reaper pattern, a new agent-controller api subcommand runs as its own Deployment with:

  • no k8s RBAC at all — it never spawns anything;
  • a registered ergo identity with write power;
  • an inbound listener on a cluster-internal Service only, bearer-token authenticated, no Gateway route.

It translates typed requests into the IRC commands the controller already validates, so the controller remains the sole authority on state and the sole IRC writer of task status. The API process is a client with a nice interface, not a second brain.

Slack ─► pi-mom (TS, LLM) ─┐
├─► agent-controller api ─IRC─► agent-controller
relay-client ──────────────┘ (Go, no RBAC, (Go, k8s RBAC,
(web console) ergo write identity) no inbound port)
agent-worker pod
(TS pi-mono harness)

Surface, roughly:

MethodPathEffect
POST/v1/tasks{repo?, description, as}!new (+ !repo), returns task id
POST/v1/tasks/:id/ready!ready
POST/v1/tasks/:id/actionscomplete | reopen | kill | archive
POST/v1/tasks/:id/messagesPRIVMSG into the task channel (steering, clarification answers)
POST/v1/askephemeral request — see below
GET/v1/taskssnapshot (or defer to dashboard-backend, which already serves this)

This is the same component plan-agent-task-console.md designed as console-backend. Build it once, serve both clients. Two write-proxies with two attribution schemes is the outcome to avoid.

Two request classes: task and ask

Skip the ceremony for turn-shaped work, never the record.

taskask
Shapeproduces a reviewable artifacta question with an answer
Lifecyclepending → ready → in_progress → for_review → donenone
Executionephemeral Job + PVC per taskrouted to a warm capability Deployment
Channel#task-N / #<repo>-task-Nnone
Slotcounts against MaxActiveTotaldoes not
Recordfull: channel transcript, METADATA, firehoseask_started / ask_completed on the firehose
Examplesweb-developer, popquizgeneral (Prometheus, Matomo, Airtable)

A cold pod per lookup is the wrong substrate — schedule + pull + clone to answer “what’s the load on thoth?” Ephemeral pods are right for task work; ask routes to the warm Deployments that already serve it. One dispatcher, two execution substrates, chosen by work shape.

Work shape is a property of the request, not of the agent

A capability declares which shapes it supports; the caller picks one per request. Binding a capability permanently to one shape would be a modelling error — the same agent legitimately serves “check this page” and “audit the whole site”.

CapabilitySupportsNotes
web-developertaskClones, edits, pushes a branch, opens a PR. Loses state on restart today
popquiztaskSame shape — edits app data and pushes
accessibility-testerbothAn audit is a task: minutes of work, a durable WCAG report, and a for_review that genuinely means “a human read the findings”. A single-page spot-check is an ask. Task dispatch also reclaims its 2 CPU / 2 Gi standing reservation, which is idle almost always
generalaskLookups and monitoring. Latency-sensitive, no artifact

general is ask-only for now because nothing it does produces a reviewable artifact — but that is an observation about its current skills, not a constraint. The registry expresses it as a list, so it costs nothing to widen later.

Where things live — promoting the shared tier

The tree still says “two stacks” because until now it was true. A shared tier already exists and works — base/agent-wrapper is a base-chain image under six agents, the IRC worker and slack/bot, and agents/* sits at the root — so this is extending an established pattern, not inventing one.

What stops being client-specific under this plan:

TodayBecomesWhy
irc/agent-controller/shared orchestrationServes Slack from Phase 2; “irc/” becomes a lie
irc/dashboard-backend/shared read modelAlready generic — it renders an event contract, not IRC
irc/agents-workspace*/shared clientRenders the firehose; nothing IRC about it
[[agent]] blocks in vibes-slack.tomlshared capability registryTwo consumers from Phase 3 (pi-mom’s tools, the ask router), soon three
firehose event vocabularyshared schemaAlready the contract between four components

What stays client-specific, correctly: slack/bot/ (pi-mom, its patches, mrkdwn formatting), slack/channel-tailer/, irc/relay-client/ and The Lounge (they are IRC clients), and ergo’s own config.

A plausible destination — orchestration/{controller,read-model,worker,registry,schema} and clients/{slack,relay,workspace-2d,workspace-3d}, with agents/ and base/ unchanged. But see the sequencing rule below: the name change lands with the change that makes it true, never before. Renaming irc/agent-controller while it is still IRC-only buys nothing and costs a CI rewrite — the docker-images--vibes.yml bake DAG is path-driven, image names embed vibes/irc/, and every infra/k0s/vibes/*/kustomization.yaml pins digests by path.

Splitting the config: capability registry vs client scoping

vibes-slack.toml currently mixes two kinds of fact. Separate them by asking would an IRC-only deployment need this?

Shared — the capability registry. What a capability is: id, description, endpoint Service, worker pod template, secret name, default provider + model, and shapes: [task, ask] per the section above. This is what the ask router resolves and what per-task pod/secret selection (Phase 1) reads.

Client-specific — scoping and presentation. Which Slack channels may invoke which capability (pi-mom-acl), the MOM_* toggles, TAILER_CHANNEL_IDS. Each client scopes independently; the registry never mentions a channel id.

One thing to reconcile rather than sort: web-developer-channel-repos.yaml and IRC’s !hub/!repo binding are the same concept implemented twice — “which repo does this work target”. Under unification that should be one repo-binding model, with a Slack channel being one way to default it, exactly as a repo hub defaults it on the IRC side. Two sources of truth for a repo binding is precisely the class of duplication this plan exists to remove.

Slack can carry the full workflow — turnstone already proves it

The obvious objection to routing task-shaped work through Slack is that the review gate and permission prompts need interaction, and pi-mom’s Socket Mode has no inbound port. That objection is already disproven in a repo we run. turnstone/channels/slack/bot.py runs slack_bolt’s AsyncSocketModeHandler and serves interactive Block Kit buttons over it — Slack delivers block_actions payloads down the same socket, so no public Request URL and no inbound port are required.

turnstone is reference only. It is not modified, not adopted, and not in the dependency path of anything here — pi-mom remains the Slack front-end precisely because it is ours to patch (four patches already, docs/pi-mom-patches.md). What follows is a set of proven design answers to copy in TypeScript, not code to import:

Mechanicturnstone’s answerWhat it solves here
Approve / Reject / Always Approve buttons on a tool callBlock Kit over Socket ModeThe [PERM id] gate, and for_review accept/reopen
Static custom_ids, correlation (ws_id, correlation_id) carried out-of-bandbuttons survive a bot restartA naive build puts state in the button payload and loses it on every rollout
Route key is a triple (channel, user_id?, thread_ts?), round-tripped to an opaque id and persisted in channel_routesre-subscribed on restartDirectly fixes the thread-collision hazard below — the wrapper keys on channel_id alone today
channel_users maps (channel_type, channel_user_id) → internal user; self-service /link via modalreal per-user identityThe attribution prerequisite in Phase 2
auto_approve_tools allowlistper-tool auto-approvalBetter than IRC’s all-or-nothing AGENT_REQUIRE_APPROVAL

Also worth copying: only the user who owns the workstream may approve or reject, and only linked users can press the buttons at all.

So the review gate is not a reason to keep Slack file-and-forget. Accept/reopen and [PERM] approve/deny can both live in the originating Slack thread, with the completion signal that already exists (notifyRequester, bot.go:376) as the trigger.

On message schemas: no to FIPA, yes to the instinct

munder-difflin’s FIPA-lite speech acts (request | inform | propose | query | agree | refuse | done, plus hops and requires_reply) are a good fit for munder’s problem, which we do not have. Those fields exist to stop two peer agents ping-ponging forever. In this design the topology is a star — clients → dispatcher → worker — and workers are per-task, isolated, and never message each other. The anti-livelock machinery would have nothing to prevent.

Nor is it meaningfully “more standard”: FIPA-ACL has been dormant since the mid-2000s and nothing in the current agent ecosystem implements it, so adopting it buys no interop, no libraries, and no tooling. The live standards are MCP (which turnstone already supports) and structured tool-calling.

But the instinct is right, and it points at a real problem. The IRC wire protocol is an accreting bag of ad-hoc string markers — [status] X, [meta] k=v, [PERM id] tool: preview, → tool, / — and relay-backend-plan.md proposes adding [PERM id risk] … :: why and [ASK id] prompt || A) label :: desc on top. That is drifting toward an unversioned protocol parsed by regex on both ends.

The typed schema to standardize on already exists: the #firehose event vocabulary. It is versioned (v: 1), has a Go emitter and a TypeScript parser, and three consumers. The rule going forward: new signals become firehose event kinds with typed fields, not new in-channel string markers. The one idea worth borrowing from FIPA is narrower than an ontology — that a message should declare whether it expects a reply. That is one boolean on the clarification event, not seven acts.

(If peer-to-peer agent messaging is ever added, revisit. It is not in this design, and worker isolation is one of agent-controller’s better properties.)

Why this approach (and not the alternatives)

OptionVerdict
Leave two stacks; give pi-mom its own ledgerrejected — a second orchestration lineage to build and maintain, and still no unified record
Clients speak IRC directly as a privileged proxyconsidered — preserves “IRC is the API” exactly, but forces every client to be an IRC client, and puts the write identity in the LLM-holding process
A separate gateway service, unrelated to the controllerrejected — same shape as the chosen option, but a third codebase instead of a subcommand of the one that already owns the command grammar
Typed API as an agent-controller subcommandchosen — one command grammar, one attribution scheme, and no inbound port on the k8s-privileged process

Honest cost: this breaks the stated invariant that all control happens via IRC (dashboard-backend README; plan-irc-ai-agents §“Out-of-scope for the dashboard”). Defensible — IRC becomes the substrate rather than the API — but it is a real posture change, and the reason the API process gets no cluster credentials.


Sequencing

Execution order — revised 2026-08-23

The IRC agent stack has no users. A read-only LIST against ergo returned five channels — #tasks, #firehose, and three seeded repo hubs — and zero task channels. Since !archive never deregisters, any task ever filed would still have a registered channel; there are none, and no pvc-task-* PVCs either. (A PVC reset would wipe channels while SEED_REPOS recreates the hubs, so “never used” and “reset since last use” are indistinguishable — but nothing is running now either way.)

IRC is nonetheless staying as a supported client option, so nothing below removes it; the IRC command surface is preserved in full, as an adapter.

That fact reorders the plan, because the original sequence was shaped by don’t break the running IRC system and there is no running IRC system. Phase numbers are kept so existing cross-references still resolve, so they no longer read in execution order:

OrderPhaseChange from the original plan
10 — unpin the provider✅ built (undeployed); independent of everything
20.5 — store authoritative✅ built (undeployed); steps 1+2 merged: no dual-write waiting period
30.5 step 3 — typed worker ingest✅ built (undeployed); prerequisite for the next line
41.5 — IRC becomes a clientnew; prerequisites now met, and at its cheapest
52 — typed APIreframed: native interface, not an IRC proxy
61 — per-task worker shapeunchanged; can land any time before 5
73 → 4 → 5 — ask, pi-mom, migrateunchanged; the Slack-facing work

Three consequences worth stating plainly, because each retires earlier advice:

  • Phase 0.5’s dual-write evidence period is pointless here. Step 1 exists to prove the store agrees with IRC before reads flip. With zero tasks there is nothing to compare, and a week of drift logs would prove nothing. The mirroring code already written stays (it is how writes reach the store); the waiting goes.
  • The decoupling will never be cheaper. Its risk was almost entirely “breaks production,” and there is no production. Every task filed from here raises the cost.
  • Phase 2 as originally designed should probably not be built. Translating HTTP into !commands typed at a chat server exists to preserve an inbound interface with no users. Decouple first and the API is simply the controller’s own interface.

Preconditions

  • Host disk. Cleared 2026-08-23: the virtiofs mount backing /operator/{notes,repos,files} was 100% full on 2026-08-22 (461 G, ~285 M free) and a write there failed with ENOSPC. It now reports 70% used / 143 G free. Nothing in this plan was ever blocked by the design; it blocked image builds, git and Docker. Re-check before a build session, since it is unrelated to this work and can refill.
  • The one open blocker is block_actions support in pi-mom (see Open questions). It gates Phase 4 only — Phases 0–3 can start without an answer.

Cross-cutting rule: directory moves ride the change that makes them true

A component moves when the change that makes it shared lands — never before, never as its own commit. agent-controller moves in Phase 2 (when it starts serving Slack), the registry directory is created in Phase 3 (when it gains a second reader), the renderers move whenever plan-agent-workspace-renderer.md next touches them.

Each move costs the same four edits, so keep them small and attached to real work: the docker-images--vibes.yml bake DAG (path-driven), the image name (which embeds vibes/irc/), the infra/k0s/vibes/*/kustomization.yaml digest pin, and doc cross-references. A big-bang reorg pays all of that at once for no functional gain and makes every in-flight branch conflict.

Phase 0 — unpin the provider (early, and useful on its own)

Everything else moves work onto the worker runtime, so widen it before migrating onto it. Independent of Phase 0.5 — different files, no ordering constraint between them; this one is breadth, that one is foundation.

  • internal/bot/models.gomodelTiers becomes provider-qualified; resolveModel() returns (provider, id) rather than id.
  • internal/spawn/spawn.go:248LLM_PROVIDER from the spec, not a literal.
  • spawn.go:250ANTHROPIC_API_KEY becomes a provider-dependent secret ref instead of a required one. MS Foundry and OpenRouter are OpenAI-compatible base URLs, so they need a base-URL env too. Corrected 2026-08-23 against pi-ai source: only Azure / MS Foundry needs endpoint config. OpenRouter is a first-class pi-ai provider carrying its own baseUrl (https://openrouter.ai/api/v1) and reading OPENROUTER_API_KEY — the key is all it needs. Azure wants AZURE_OPENAI_BASE_URL, or AZURE_OPENAI_RESOURCE_NAME to derive it, plus an optional AZURE_OPENAI_API_VERSION.
  • !model gains provider-qualified choices; model METADATA already rides to spawn, so the transport exists.

Roughly 60 lines of Go plus secret wiring.

The harness side needs no work at all — verified 2026-08-22 against /operator/repos/pi. Superseded 2026-08-23: true of pi-ai’s provider support, false of the wrapper — base/agent-wrapper carried its own two-provider allowlist and a silent wrong-model fallback. See “Harness side” under Phase 0’s status. The paragraph below is still correct about pi-ai itself. pi-ai ships ~40 first-class providers in packages/ai/src/providers/, including every one named as a requirement: anthropic, openai, openrouter, and azure-openai-responses (id azure-openai-responses, name “Azure OpenAI” — the MS Foundry path), plus amazon-bedrock, google, google-vertex, groq, mistral, xai, deepseek, cerebras, together, fireworks, nvidia, github-copilot and more. Each carries its own env-var auth (AZURE_OPENAI_API_KEY, etc.) and its own model list.

So the Anthropic pin is entirely ours, and entirely in Go. This phase is smaller and lower-risk than it looks: three edits in agent-controller plus per-provider secret wiring. Nothing in the TypeScript harness changes.

The secret wiring is smaller than “per-provider secret wiring” suggests — checked 2026-08-23. The agent-worker ExternalSecret uses dataFrom: extract against kv/eso/agent-worker, mirroring every kv field 1:1 into the Secret. So a new provider key is make k0s-openbao-kv-patch SVC=agent-worker FIELD=OPENROUTER_API_KEY and a matching binding in spawn.gono manifest change. Two consequences:

  • The ExternalSecret’s comment (“Keep this kv path to exactly the two fields below; anything put here lands in the Secret”) becomes wrong the moment a third key is added. Update it in the same commit, or the next reader trusts a stale invariant.
  • An OpenRouter key already exists in the org — the general-agent and turnstone blocks of initial-kv-seeds.sops.yaml both carry OPENROUTER_API_KEY. So the verify step below has a ready non-Anthropic target and needs no new vendor account.

One caveat inherited from Phase 1: AGENT_SECRET_NAME is a single shared secret today, so any provider key added here is visible to every worker. Acceptable for a scratch verification run; it is the same broadening Phase 1 exists to fix, so do not treat this as the permanent shape.

One design note that follows: pi-ai maintains a generated model catalog (models.generated.ts, model-catalog.ts). The hard-coded modelTiers table in Go is a second, hand-maintained model list that will drift from it. Prefer keeping the Go menu deliberately thin — provider + a short alias set — and let the worker validate the model against pi-ai’s catalog at startup, rather than mirroring it.

Verify: run one scratch IRC task on a non-Anthropic provider end to end; confirm !model menu, spawn env, and tool telemetry all behave.

Status: controller-side implemented 2026-08-23

models.go, bot.go, config.go, spawn.go and the agent-worker ExternalSecret comment are done. A task now carries a (provider, model) pair — always set and read as a unit, so a task can never pair a model with a provider that doesn’t serve it. !model keeps the three Anthropic tier aliases and gains a <provider>/<model-id> escape hatch; the split is on the first / only, since OpenRouter ids contain one themselves. The allowlist (providers in models.go) is anthropic, openai, openrouter, azure-openai-responses. Only the selected provider’s credential is bound, and it is bound required, so a provider whose key is missing fails the pod rather than falling through to another provider’s creds. The credential sits in the exact slot ANTHROPIC_API_KEY occupied, so an anthropic task’s env is unchanged entry-for-entry. New provider METADATA key; worker_spawned now carries provider.

Deliberately not mirrored: model ids are never validated in Go. The allowlist covers providers only — that is what picks a credential. This follows the plan’s own note about models.generated.ts drift.

Verified: internal/bot builds and its full test suite passes, including 9 new provider tests (qualified choice, first-slash split, allowlist rejection, tier-choice resets provider, legacy model-only METADATA defaults, firehose field, menu). All changed files are gofmt-clean and parse.

Fully verified 2026-08-23 on moby@10.112.113.191 (eren, Docker 29.5.1). The whole module graph resolves and the whole suite passes, internal/spawn included. Verification is just building the image: agent-controller/Dockerfile already runs go mod download, go vet ./..., go test ./... and go build, and fails the image on any of them. All four passed — bot, spawn and reaper ok, binary built — and the 13 new tests were re-run verbosely against the builder stage to confirm they execute rather than being skipped.

The earlier go.sum checksum mismatch was this sandbox, not the repo. The same go mod download that fails locally on github.com/ergochat/irc-go@v0.6.0 completes in 16s on a host with clean egress. Nothing is wrong with go.sum; the local network is TLS-intercepted. Worth remembering the next time a Go or npm fetch fails oddly in here — and worth not working around with GONOSUMCHECK or GOFLAGS=-insecure.

Harness side: two more pins, and a live bug — fixed 2026-08-23

The claim that “the harness side needs no work at all” was wrong, and finding out why turned up a production bug. base/agent-wrapper had its own provider pin and its own silent-failure mode:

  1. A second provider allowlist. PROVIDER_KEY_VAR (agent.ts:21) listed only anthropic and openrouter, and setupProvider exit(1)s on anything else — so the controller’s openai and azure-openai-responses would have been rejected by the worker. Replaced with a PROVIDERS table mirroring the Go allowlist, with a comment on each side pointing at the other. Azure also now fails at boot if it has neither AZURE_OPENAI_BASE_URL nor AZURE_OPENAI_RESOURCE_NAME, instead of at first use.
  2. getModel silently substituted a different model. agent.ts:43 was allModels.find(m => m.id === modelId) ?? allModels[0] — an unknown id ran the catalog’s first model with no warning. Now throws, listing the valid ids. index.ts resolves the model once at boot rather than per-request, so a bad LLM_MODEL is a failed start rather than a healthy container that dies on first request with /health lying in the meantime. irc-task.ts needed no change — its main().catch already logs and exits 1.

The live bug this exposed. modelTiers had opusclaude-opus-4-8, which is not in pi-ai 0.73.1’s catalog (23 anthropic models; newest opus is claude-opus-4-7). So !model opus — “most capable” — has been silently running claude-3-5-haiku-20241022, the catalog’s first entry and about the weakest model available. Every task that ever chose opus got 3.5 Haiku. Corrected the tier to claude-opus-4-7 and documented the check to run before bumping a tier id. haiku, sonnet and the WORKER_MODEL default were all verified present.

This is the argument for the fix in miniature: the fallback did not prevent a failure, it hid one for as long as the id was stale.

Verified against the real pinned deps (npm ci, integrity checks passed): tsc --noEmit clean under strict; both esbuild bundles build; the bundled binary exits 1 with the valid-id list on a bad LLM_MODEL; all four allowlisted providers resolve in pi-ai 0.73.1 (anthropic 23, openai 42, openrouter 275, azure-openai-responses 42); and every (provider, model) pair the controller can emit resolves in the worker.

Blast radius. base/agent-wrapper is a base-chain image under six agents, the IRC worker and slack/bot, so this rebuilds more than the controller does. It is a separate commit from the Go change for that reason. The behaviour change is deliberate: a container that would previously have run the wrong model now refuses to start. Anything currently relying on the fallback — i.e. running a model nobody chose — will surface as a boot failure naming the bad id. That is the point, but it should ship when someone can watch it.

Remaining before the verify run: add OPENROUTER_API_KEY to kv/eso/agent-worker (make k0s-openbao-kv-patch SVC=agent-worker FIELD=OPENROUTER_API_KEY); build and deploy the controller and the wrapper chain.

Phase 0.5 — give the controller a state store; IRC becomes a projection

Independent of Phase 0 — the two touch different code and can land in either order. Numbered 0.5 because it gates more: Phase 0 is breadth (a wider LLM menu, byte-identical by default), while this is the record model that Phases 2–5 add writers, volume, and a second work shape to.

Why it can’t wait. Four things verified in source on 2026-08-23 say the model does not hold:

#FindingEvidence
1The controller is founder of every task channel and !archive never drops the registration, so registered channels accumulate monotonically against a 15-channel cap the controller has no exemption from. Past it ChanServ REGISTER fails — silently, because the controller registers five callbacks (PRIVMSG, JOIN, 322, 761, 323) and never reads a NOTICE. ergo is set unregistered-channels: false, so that task gets no persistent history and no persisted METADATA: it lives only while a client is in the channel, then drops out of LIST and is gone on the next controller restartergo.yaml:235, opers: {}, bot.go:637, bot.go:1111, ircconn.go:99-125
2The record has a 1-week TTL and a 2048-message ceiling per channelergo.yaml history.restrictions.expire-time: 1w, channel-length: 2048
3The firehose — this plan’s “typed schema to standardize on” — is regex-scraped out of chat prose. The worker prints → bash: cmd / ✓ bash / [PERM id] tool: preview into the channel; the controller pattern-matches those lines and re-emits JSONfirehose.go:25-28, firehose.go:54-82
4Firehose events over 400 bytes split into two PRIVMSGs — two invalid JSON lines — and the reader drops both without logging. A tool_call_start with a 300-char args runs ~450 bytes; long git/gh/curl commands are the common case, so the shared contract already loses events todayircconn.go:58, firehose.go:62, dashboard-backend/main.go:53

Finding 1 is the urgent one: the plan’s stated elegance — “the controller holds no persistent store” (bot.go:5-9) — degrades to no store at all with no error path.

Finding 3 is the structural one. The rule adopted in “On message schemas” — new signals become firehose event kinds, not new in-channel string markers — cannot be followed while a worker’s only path to the controller is typing into a chat channel. The firehose does not replace the string protocol; it launders it.

The change. SQLite on a PVC mounted into the controller. Roughly 200 lines plus a migration. No new schema language and no new vocabulary — this is the existing firehose event contract given a durable home and a typed ingress.

Storage preconditions, checked against the live cluster 2026-08-23:

  • The controller has no PVC todayagent-controller.yaml runs readOnlyRootFilesystem: true with the comment “static Go binary; logs to stdout, writes nothing.” This phase creates one. readOnlyRootFilesystem can stay true: it governs the root filesystem, and a mounted volume remains writable.

  • replicas: 1 + strategy: Recreate are already set (“never run two controllers at once”), which is exactly what a single-writer SQLite on an RWO volume needs. No strategy change, and no new argument to win — the constraint is already the design.

  • Node pinning is the real cost. ergo-data is RWO on local-path, i.e. node-local; a controller PVC on the same class pins the controller to a node. ergo already has this property, so it is a precedent rather than a new class of problem — but it is a scheduling constraint the controller does not have today, and it belongs in the rollout notes.

  • Tasks become rows rather than a channel plus a topic prefix plus METADATA keys: id, repo, status, title, description, requester, assignee, job, provider, model, timestamps. setStatus (bot.go:343) becomes one transaction instead of a topic write plus N unacknowledged METADATA SETs that can half-apply across a disconnect.

  • The event log becomes a table, and #firehose becomes a fan-out of it rather than its home. Consumers wanting history get a query; consumers wanting live get the channel, or SSE from dashboard-backend, which already serves exactly this. Event size stops mattering — finding 4 becomes a rendering concern, not data loss.

  • A worker → controller path that is not a chat line. The wrapper already speaks HTTP; the controller gains a cluster-internal ingest endpoint carrying the same tool_call_* / status / meta / permission events it currently reconstructs by regex. Channel narration stays — it is genuinely good — but becomes a rendering of the typed event rather than its source. This is what makes the no-new-string-markers rule enforceable instead of aspirational.

  • IRC keeps what it is actually good at: the readable channel, multi-client access (The Lounge, gamja, relay-client), the conversation itself, presence, and clarification turns. The channel becomes a projection the controller maintains, and reconstruction-from-LIST becomes a repair path rather than the only truth.

What this unblocks downstream — why it is cheaper here than later:

  • Phase 2’s API stops being strange. As designed it translates HTTP into !commands typed at a chat server by a privileged proxy, solely to keep “the controller is the sole IRC writer” true. Against a store it reads and writes the same rows the controller does. The RBAC-free separate-process posture is unchanged — that property comes from the deployment, not from the IRC round-trip.
  • Attribution stops being a parsing hack. The trusted [as <nick>] prefix — this plan’s own listed risk, which Phase 2 is gated on — becomes a column rather than a prefix scraped off a chat line.
  • ask stops being an anomaly. This plan already concedes an ask “maps to nothing” in IRC. A record with no channel projection is unremarkable once the record is not the channel. Without this, Phase 3 reintroduces two record models — precisely the duplication this plan exists to remove.
  • Capacity becomes answerable. MaxActiveTotal becomes a query rather than a scan of reconstructed state, and finding 1’s cap stops being reachable at all.

Honest cost. This retires “the channel is canonical” (plan-irc-ai-agents, “Data model”) — that plan’s founding claim and its most elegant property. It is a larger posture change than the API’s, and belongs in the same paragraph as it. The mitigating fact: this plan already breaks the weaker form of that invariant, and Phases 3–5 break it further. This phase makes the break deliberate and bounded rather than incidental.

Order within the phase — revised 2026-08-23 on the “no IRC users” finding.

The original order was (1) measure the cap, (2) dual-write with IRC authoritative, (3) flip reads after a drift-evidence period, (4) typed worker ingest. Steps 2 and 3 are now merged: the evidence period existed to prove the store agreed with IRC before trusting it, and with zero tasks there is nothing to compare. A week of drift logs against an empty system would prove nothing.

  1. Measure the cap. Done — see below. Zero task channels.
  2. Store authoritative from the start. Built 2026-08-23 (5d636016). The mirroring is already written and committed (internal/store + the four choke points); what went is the waiting. A repair subcommand (reconstruct the store from LIST) was considered and dropped 2026-08-24 — there is nothing in IRC worth reseeding from (the “no users” finding), and rebuilding the primary from a lossy replica perpetuates the two-records model this plan exists to remove. The rollback is the flag itself (AGENT_STORE_AUTHORITATIVE=false resumes mirror mode and reconstructs from IRC automatically); real store recovery is the .db restic backup. See the 2026-08-24 decision-log entry.
  3. Typed worker ingest Built 2026-08-23 — promoted, because it is the prerequisite for Phase 1.5. While a worker’s only route to the controller is posting into its channel, IRC cannot leave the controller without severing the worker path.

The drift check keeps its value even so. It stops being a gate and becomes a regression alarm: once IRC is a projection, it reports the projection falling out of step with the record — which is exactly the failure a projection can have.

Step 1 ran 2026-08-23 — the answer is “correct, not urgent.” A read-only LIST over the gateway websocket (wss://ws.irc.rrchnm.internal, anonymous, no JOIN or write) returns 5 registered channels: 3 repo hubs (#popquiz, #rrroster, #systems) and 2 standing (#tasks, #firehose). Zero task channels. So the 15-cap is roughly 10 task channels away, not already breached — finding 1 is a latent cliff with no error path, not a live outage. Build this phase deliberately rather than as an incident, and note the two small independent fixes in the design doc that bound the cliff on their own (!archive should ChanServ DROP; cmdNew should read ChanServ’s NOTICE).

Caveat: that counts what LIST returns, and every channel seen had ≥2 occupants, so nothing proved LIST surfaces registered-but-empty channels. If it doesn’t, the count understates — and that is its own finding, since reconstruction reads the same LIST. One kubectl exec settles it; the read-only agent-view context used here has no pods/exec, pods/portforward, or Secret read.

Design written 2026-08-23: vibes/irc/agent-controller/docs/state-store.md — full schema (tasks, repos, task_status_history, events), the METADATA→column map, the four-step build order, deployment constraints, and open decisions. Nothing implemented yet; that doc is what to build against.

Status: all three steps built 2026-08-23 — deployed + verified live 2026-08-24

Step 1 — dual-write. internal/store (SQLite via modernc.org/sqlite, the pure-Go driver, because the image builds CGO_ENABLED=0), wired at the four mutation choke points, plus DriftCheck on the sweeper tick. The controller previously had no PVCreadOnlyRootFilesystem: true, “writes nothing” — so this phase creates one; AGENT_DB_PATH is opt-in and a store that won’t open is logged and skipped, never fatal.

Step 2 — the flip (5d636016, 7 files, 9 new tests). Decisions worth keeping:

  • The flip is its own flag, AGENT_STORE_AUTHORITATIVE, deliberately not a consequence of AGENT_DB_PATH. Flipping against an empty store reads as every task disappearing at once, and the controller would archive-by-omission.
  • The write path is unchanged. metaSet and setTopic still emit the same IRC commands, so The Lounge, gamja and relay-client see exactly what they saw before. What moved is where the controller believes state comes from.
  • Reconstruction from LIST/METADATA now writes into a separate ircView, so the drift check has a real left-hand side instead of comparing the store to itself. The check inverts with the flag and the log line names the direction (“store mirrors IRC” vs “IRC projects the store”).
  • A message in an unknown task channel no longer invents a task — under the old model a stray PRIVMSG would write a phantom into the record.
  • An unreadable store under the flag is fatal, for the same reason the flag is separate.

Step 3 — typed worker ingest. POST /v1/tasks/{id}/events with a per-task bearer token (crypto/rand, 32 bytes hex; never nanoID, whose timestamp fallback is wrong for a credential), compared with subtle.ConstantTimeCompare. Tokens are persisted (schema migration 2) because a token held only in memory would be forgotten on reconnect and every later event from a running worker would 401. The wrapper side is in base/agent-wrapper/src/irc-task.ts: a fire-and-forget ingest() closure, active only when both CONTROLLER_INGEST_URL and CONTROLLER_INGEST_TOKEN are set, so a worker with no ingest configured behaves exactly as before. Ingest requires the store and refuses to start without it, saying why.

This retires finding 3 for the events that matter: worker state no longer round-trips through regex-scraped chat prose. The firehose scraper stays for now — it also carries tool telemetry the ingest path doesn’t yet cover.

Verified on moby@10.112.113.191 (eren): go vet clean, all five packages green (bot, ingest, reaper, spawn, store). The cross-language contract was tested with payloads generated by actually running JSON.stringify on the wrapper side rather than hand-written fixtures.

Gate before flipping AGENT_STORE_AUTHORITATIVE (revised 2026-08-24): originally a repair subcommand that backfills the store from LIST; dropped — the rollback is the flag (off → mirror mode reconstructs from IRC), and real recovery is the .db restic backup, so the gate is now “the .db is in restic-backup-vibes and a restore has been exercised at least once,” not a repair path. Do not go authoritative with neither.

Verify: kill the controller mid-transition and confirm no task ends up with a topic and a status that disagree; archive 20 tasks and file a 21st (today’s ceiling is 15); run a task whose tool calls carry 300-character commands and confirm every event reaches dashboard-backend intact; confirm a task list survives a full history-expiry window.

Phase 1 — per-task worker shape and credentials

  • Per-task pod template selection, mechanically like !model (METADATA → spawn). Required before web-developer can migrate: it needs gh/git, and accessibility-tester would need Chromium + Pa11y.
  • Per-task secret selection. Today one shared AgentSecretName; web-developer’s PAT is fine-grained and scoped to exactly the repos in web-developer-channel-repos.yaml. Broadening it across every task is a security regression and is not acceptable.

Verify: two tasks in the same controller spawn different pod shapes with different scoped secrets.

Phase 1.5 — IRC becomes a client (new, 2026-08-23)

Status — chunks 1+2 built + (mostly) verified live 2026-08-25. The extraction is being done in three reviewable chunks, ordered so the first two land in mirror mode (no backup/flip needed) and only the last rides the AGENT_STORE_AUTHORITATIVE flip. Both seams a client needs — drive + observe — now exist and are proven in production; the controller is client-agnostic.

  1. Typed command API (inbound seam)9e753812 + deploy 5d623867, VERIFIED LIVE 2026-08-25. internal/api HTTP server + internal/bot/api.go transport-neutral API* methods that reuse the existing domain ops (createTask, cmdReady, …) — one state machine, two front doors. AGENT_API_ADDR/AGENT_API_TOKEN, off by default (token seeded to OpenBao kv/eso/agent-controller). Phase 1 was closed THROUGH this API, sidestepping the broken IRC hub-join entirely: POST /v1/tasks with a per-task pod/secret → /ready → the worker pod showed mem-limit 3Gi, PVC 7Gi, and creds bound from the per-task secret. No ergo hub involved.
  2. Event stream (outbound seam) — built, moby-green.
    • 2a GET /v1/events?since=&limit= cursor read (a8b0e5a8), VERIFIED LIVE: returned the full faithful lifecycle of the API-driven task (task_created→…→worker_exited). Reads the store’s event log directly (no bot lock).
    • 2b GET /v1/events?stream=1 live SSE tail (a60704cb): an in-process internal/events.Broadcaster emit() publishes to; subscribe-then-replay- then-live with id-dedup so no gap/dup at the seam; SaveEvent now returns the durable id the tail keys on. Awaiting merge+deploy to verify live. #firehose is untouched — it stays a fan-out; the api stream is a peer.
  3. Extract clients/irc. Move ircconn + the 3 command switches + projections + hub-join + reconnect-rebuild into a separate binary; the controller drops its IRC field and ergo import. Requires the store to be authoritative (the controller can no longer rebuild from IRC), so this is the one chunk gated on the backup/flip.

Discovered en route (both real, both fixed/queued): the IRC hub-join is broken — the controller sends JOIN #<repo> on reconnect but ergo does not seat it (#firehose register also fails “must be an oper” every boot), so !repo in a hub draws no reply; and cmdBindRepo copied repo/url/branch but not pod/secret, so a repo-less-draft-then-bound task silently used controller defaults (fixed in aab06bf2). The command API sidesteps the hub-join bug entirely, which is exactly the point of decoupling — Phase 1 was verified over it while the hub stayed broken. The hub-join/ergo-bootstrap issue is still open, but no longer blocks anything: it only matters for driving tasks from an IRC hub, and Chunk 3 will move that whole surface into the clients/irc adapter anyway.

Chunk 3 — extraction plan (scoped 2026-08-25; execute when the flip lands)

Split 3a/3b/3c (buildable NOW, mirror mode) + 3d (the cutover, flip-gated). Building 3a–3c ahead means the flip triggers only a focused, reversible cutover.

Target. Controller = a headless HTTP service: state machine, store (authoritative), scheduler, k8s spawner, command api (/v1/tasks), event stream (/v1/events), worker ingest. No ergo, no ! parsing, no b.irc.*. clients/irc = a separate binary: the ergo connection + !command→api translation + event-stream→IRC rendering + hub-join/reconnect. ergo goes optional.

3a — complete the command api (additive, deploy now). The adapter can only translate a command the api serves; Chunk 1 covers create/ready/kill/complete/ reopen/model/desc/get/list. Add: POST /v1/hubs (!hub), GET /v1/hubs, GET+POST /v1/hubs/{name} (list/get/edit url·branch·pod·secret — the !repo hub form); POST /v1/tasks/{id}/repo (bind a draft — !repo <name>), /archive, /permission (!a/!d); GET /v1/tasks?status=&repo=&mine= (!tasks filters). Each = a bot API* method reusing the existing op + route + test. !help/!status need no api.

3b — event→IRC renderer (a library in clients/irc). A materialized view: bootstrap a task/hub cache from GET /v1/tasks + /v1/hubs, keep it current from /v1/events, render each event to IRC — the projection the domain ops do inline today, moved out. Table:

eventIRC render
task_createdregister #chan, join, topic [pending] title, metaSet(status,requester,description,created,repo,url,branch,pod,secret), Privmsg desc, invite requester, Notice hub+#tasks+requester
statustopic [<status>] title, metaSet status; blocked/for_review/done → DM requester
task_readyPrivmsg “ready”
worker_assigned / worker_spawnedmetaSet(assignee,spawned,job), Notice #tasks
worker_online / worker_exitedNotice / (status carries the rest)
task_updatedmetaSet changed fields
task_completedPrivmsg “complete”
task_archivedtopic/metaSet archived
hub_created / hub_updatedregister hub, topic [repo] name, metaSet(repo,url,branch,pod,secret)
permission_request/resolved, tool_call_*#firehose only
channel_registration_failedNotice #tasks + requester
(every event)republish as the #firehose JSON line

Renderability: enrich task_created to carry description·repo·url·branch·pod·secret (the heavy render); elsewhere the adapter uses its cache or a GET /v1/tasks/{id} fallback. Lean events + fetch-on-demand.

3c — the clients/irc binary (build+test now; deploy only at cutover — it can’t share IRC with the controller’s own connection, so it stays scaled-0 until 3d). Structure: the reconnect loop (moved from controller main) + internal/ircconn (promote to shared or copy); the command parser (the 3 HandlePrivmsg switches) → HTTP calls to the api, sender nick → actor; an SSE subscriber on /v1/events?stream=1 → the 3b renderer; the lifecycle hooks (OnConnect/OnListEnd/HandleListEntry/HandleNotice) + hub-join/registration — fix the hub-join bug here (now the adapter’s concern); config = ergo+SASL (moved) + api url/token + event url.

3d — the cutover (flip-gated). In order: (1) flip AGENT_STORE_AUTHORITATIVE=true (the gate — needs backup+restore first; the controller now reads state from the store, not IRC LIST+METADATA); (2) strip IRC from the controller — drop the IRC field, HandlePrivmsg+switches, all b.irc.* + projection helpers, OnConnect/OnListEnd/HandleListEntry/HandleNotice/setupFirehose, ircconn + the reconnect loop; the domain ops shed their b.irc.* calls (mutate + emit only); (3) deploy clients/irc (new Deployment, scale from 0) + a CNP allowing clients/irc → agent-controller:9101 (mirror agent-controller-ingest for :9100 — mind the Ansible-vs-Argo apply path for netpol.yaml); (4) point the adapter’s ergo account (reuse controller or a new nick; ergo-bootstrap adjusts).

Commit sequence: (1) 3a api-expansion [deploy now]; (2) 3b+3c clients/irc built [not deployed]; (3) 3d flip + strip + adapter-deploy + netpol [at flip].

Risks. Cutover is big-bang for the IRC path (controller-IRC-removal + adapter-deploy land together; if the adapter fails, IRC is dark) → mitigated by verifying api+adapter before the flip and keeping the pre-3d controller image pinnable for a flip-back rollback. Event renderability gaps → GET /v1/tasks/{id} fallback. Two IRC clients can’t coexist → 3c stays scaled-0 until 3d.

Status: SHIPPED — go-live cutover completed 2026-08-26; controller headless, clients-irc adapter live (1/1, merged to main)

The flip (AGENT_STORE_AUTHORITATIVE=true) landed + verified 2026-08-24; the IRC-ectomy cutover (the runbook below) executed 2026-08-26 — the controller now runs the headless image (sha256:48c15a01…) and clients-irc (replicas 1) carries the IRC projection, stable since. The extraction (was code-complete on cc-quiet-yarrow, now on main):

  • 3a command api + channel-free /v1/repos (internal/api, internal/bot/api.go).
  • 3b/3c the clients/irc adapter as internal/adapter (client + renderer + parser + loop), run via the agent-controller irc subcommand.
  • 3d the irc-ectomy (4a836fc0): controller runs HEADLESS — one bot on a nullIRC, no ergo, select{}; guarded by the flip. Deploy manifests (b1012bc7): clients-irc Deployment (replicas: 0), agent-controller-api Service :9101, agent-controller-ingest CNP +:9101 from clients-irc. Both built green on moby (vet+test+build).

Go-live runbook (deliberate, gated on the headless image):

  1. Merge cc-quiet-yarrowmain. Argo syncs the Service + the dormant (replicas 0) clients-irc; the controller still runs the OLD image, still IRC-connected — nothing changes yet.
  2. Apply the netpol: make k0s-vibes (netpol.yaml is Ansible-applied, not Argo).
  3. Let CI build the merged commit + the pin job update the agent-controller digest. Argo rolls the controller to the headless image (Recreate) — it drops its ergo connection. IRC projection is dark from here until step 4 (tasks, workers and the command api are unaffected — only the IRC face is absent).
  4. Flip clients-irc to replicas: 1 (one-line commit → Argo, or a stop-gap kubectl scale that a commit then makes durable). The adapter logs into ergo as controller, bootstraps its cache from the api, tails /v1/events and reconnects the IRC projection. Verify #tasks + a ! command round-trip.

Rollback: fast path — re-pin the kustomization agent-controller image to the pre-cutover digest (sha256:8dd38ec9a64c…, the in-process-IRC image current as of this commit); Argo rolls the controller straight back to speaking IRC, no CI wait. Durable path — revert 4a836fc0 (rebuild → re-pin). Either way set clients-irc back to replicas 0. The store-authoritative flip is independent and STAYS — the reverted controller still reads state from the store. The Service/CNP/dormant Deployment are inert with the adapter at 0, so they can stay across a rollback.

The gap this closes. “Proposed target state” names three layers — Clients / Dispatcher / Harness — but the dispatcher’s own row says it owns IRC writes. The controller is an IRC client. The Lounge, gamja and relay-client are end-user clients; the IRC adapter — the thing turning !new into a domain command — is fused into the controller. internal/bot/bot.go is 1,561 lines carrying 76 b.irc.* call sites and 18 !command cases alongside the scheduler and state machine.

Why it is worth its own phase rather than cleanup. Phase 2 exists because of this coupling. Its design — HTTP → !commands → chat server → controller — is what you must build when the controller has no typed inbound surface. Decoupling and Phase 2 are competing answers to the same question: how do non-IRC clients drive the controller? The original plan chose the one that preserves IRC’s privilege. With no IRC users, that choice is hard to defend, so this must be settled before Phase 2 is built, not after.

The change. Extract the IRC client into its own process — clients/irc — speaking the same typed interface as pi-mom and relay-client. The controller keeps the store, scheduler, k8s spawner and typed API, and loses its ergo connection entirely.

IRC IS NOT BEING REMOVED. It stays a supported client option, and the adapter keeps the full command surface: all 18 ! commands, the topic/METADATA projection, the readiness checklists, worker nick rendering, #firehose publication. relay-client, The Lounge and gamja keep working exactly as they do — someone built a purpose-made IRC PWA for this and that investment survives. What changes is that IRC stops being the controller’s inbound API and becomes one client among several.

What falls out:

  • The reconnect-rebuild constraint dissolves. bot.New(cfg, conn, spawner) is rebuilt on every reconnect, which is why “Facts that constrain the design” lists anything long-lived must not hold a *Bot. That rule exists only because the domain object is welded to the connection.
  • The channel ceilings stop being the scheduler’s problem. max-channels-per-client, max-channels-per-account and the fakelag JOIN storm become adapter concerns.
  • ergo becomes genuinely optional — an IRC-less deployment becomes expressible, which the current design cannot say at all.
  • The invariant gains its sibling: nothing that owns state talks to an LLM, and nothing that owns state speaks a chat protocol.

Prerequisites — both now met in code (2026-08-23), neither yet deployed:

  1. Phase 0.5 (store authoritative) — otherwise the adapter cannot be stateless, because IRC still is the state. Built behind AGENT_STORE_AUTHORITATIVE.
  2. Phase 0.5 step 3 (typed worker ingest) — workers previously reached the controller only by posting [status]/[meta] into their channel. While that held, removing IRC from the controller would sever the worker path. Built, controller and wrapper both.

The honest qualifier: met in code is not met in production. Nothing is deployed, so the adapter extraction would be cutting a seam that has never run. Deploying Phase 0 and 0.5 — and getting one scratch task through the ingest path end to end — is the thing that turns these from written to true.

After both, IRC’s only remaining roles in the controller are reading commands, rendering projections, and fanning out the firehose — all adapter concerns, which is exactly when the extraction becomes mechanical.

Honest cost. It is the largest single refactor in this plan: 1,561 lines with command parsing, domain logic and IRC rendering interleaved. The saving grace is that the domain logic is already well covered by tests that use a fakeIRC, so the seam being cut is one the tests already exercise.

Verify: every ! command works identically from The Lounge with the controller holding no ergo connection; a task filed from the adapter and one filed from the typed API are indistinguishable in the store; killing the adapter leaves scheduling and worker supervision running.

Phase 2 — the API process and attribution

Reframed 2026-08-23 — do not build this as originally written. Everything below describes a proxy: a separate process holding an ergo write identity that translates typed HTTP into the !commands the controller already validates. That shape exists solely because the controller’s inbound surface is IRC, and it was chosen to avoid putting an inbound listener on the k8s-privileged process.

After Phase 1.5 neither reason survives. The controller has a typed inbound surface, and the separate-process/no-RBAC posture comes from the deployment — a second binary with its own Deployment and no cluster role — not from the IRC round-trip. So the API stops being a translator and becomes the controller’s own interface, with the IRC adapter as a peer client rather than the substrate beneath it.

What survives from below unchanged: the endpoint surface, the RBAC-free separate Deployment, the cluster-internal Service with no Gateway route, and — most importantly — attribution. The [as <nick>] problem is real regardless of transport, and it is still what this phase must not ship without. What goes is the IRC translation layer and the “sole IRC writer” justification for it.

  • agent-controller api subcommand; separate Deployment, no RBAC, ergo write identity, bearer token from ESO, cluster-internal Service only.
  • Attribution: the trusted [as <nick>] prefix from plan-agent-task-console.md, so bot.go:630 records the real human rather than the proxy. Solve once, for both clients.
  • The handler must not hold a *Bot — that instance is rebuilt on every reconnect. Commands go over IRC like any other client’s.

Verify: POST /v1/tasks from curl creates a draft with the correct requester; kill the controller mid-flight and confirm state rebuilds from IRC and the API keeps working.

Phase 3 — the ask path, and the registry split

  • Split vibes-slack.toml per “Splitting the config” above: [[agent]] blocks become a shared capability registry (id, endpoint, pod template, secret, default provider+model, shapes); the ACL and MOM_* toggles stay Slack-side. This is the first genuinely shared config, so it is where the shared directory gets created.
  • POST /v1/ask resolves the capability from the registry, routes to the warm Deployment, returns the response, emits ask_started/ask_completed to #firehose.
  • No channel, no PVC, no slot.
  • Reconcile repo binding: web-developer-channel-repos.yaml becomes a Slack-side default feeding the one repo-binding model, not a second source of truth.

Verify: an ask returns in seconds and appears on the firehose without creating a task channel; an IRC-only deployment can read the registry with no Slack config present.

Status: built 2026-08-26 (342506ee) — controller-side, undeployed

The registry + ask path landed on cc-quiet-yarrow:

  • internal/capability — the Capability type + Validate + YAML loader; store migration #6 (capabilities table + CRUD + ResolveCapability); /v1/capabilities CRUD; declarative capabilities.yaml (the 4 warm agents, all ask-only) folded into the agent-controller-config ConfigMap (AGENT_CAPABILITIES_PATH).
  • internal/ask — the Asker: resolve capability → emit ask_started → proxy to <endpoint>/prompt off-lock → emit ask_completed. POST /v1/ask {capability, prompt, actor, session} → {response, ok, error, duration_ms}; session maps to the agent’s channel_id for conversation continuity. Agent error ⇒ 200 ok:false. New event kinds ask_started/ask_completed flow through the same store + stream as tasks; the IRC adapter republishes them to #firehose.
  • bot.Emit — the public event hook the ask path uses (brief locked calls bracketing the slow round-trip); AGENT_ASK_TIMEOUT_SECONDS (default 300).

The routing here duplicates vibes-slack.toml’s [[agent]] endpoints until Phase 4 points pi-mom’s forward_* tools at /v1/ask — expected transitional overlap; keep the two in sync meanwhile. Merged to main + deployed in the headless controller (AGENT_CAPABILITIES_PATH + AGENT_API_ADDR=:9101 wired, the 4 warm ask-capabilities reconciled from capabilities.yaml). VERIFIED LIVE 2026-08-29: POST /v1/ask {capability:general} returned "pong" ok:true in 992 ms, and created no task channel / worker pod / PVC — the ask path routes end to end and stays off the task machinery. Registry lives in the controller ConfigMap (no Slack config needed), so the “IRC-only reads the registry” criterion holds structurally. Phase 3 closed. The transitional duplication of vibes-slack.toml’s [[agent]] blocks dissolves when Phase 4 points pi-mom’s forward_* at /v1/ask.

Phase 4 — pi-mom becomes a translator

  • forward_* tools call the API instead of spawning worker.py; delete worker.py and the detached-spawn path from patch-tools.js.
  • Keep the ACL check client-side, keep channel-tail injection, keep the closed-list typed tools.
  • pi-mom holds a durable task → (channel, thread_ts) map so results and notifyRequester DMs reach the originating Slack thread. This is the ledger it has never had.

Verify: a Slack request produces a firehose event, and its result lands back in the right thread across a pi-mom restart.

Phase 5 — migrate the work that wants a lifecycle

web-developer and popquiz move to task dispatch. accessibility-tester gains task alongside the ask path it already has — a site audit files a task, a single-page check stays conversational; this is the first capability to exercise both shapes and therefore the real test of the per-request model. general stays ask-only.

Run both dispatch paths in parallel for one release before removing the direct-POST path.

Verify: a Slack-filed web-developer request appears as #<repo>-task-N, is watchable and steerable from relay-client by an IRC user, and opens the same PR it does today. Separately, the same accessibility-tester capability answers a spot-check in seconds and files an audit task — without duplicated registry entries.

Phase N — reconcile the docs

plan-irc-ai-agents (IRC is no longer the only control surface, and after Phase 0.5 its §“Data model” rule — “the channel is canonical… no separate state store” — is retired; its §“Divergences” is the right place to record that), plan-agent-task-console.md (its console-backend is this API process, and its read-model section predates the store), slack/AGENTS.md (pi-mom’s role changed), and plan-agent-workspace-renderer.md (its Phase 4 largely evaporates).


Risks

  • An inbound surface adjacent to the only k8s-privileged component in the namespace. Mitigation: the API is a separate process with no RBAC, no Gateway route, cluster-internal Service, bearer token. It can create tasks; it cannot create pods. Worst case is task spam, not cluster compromise. vibes-isolation already governs same-namespace traffic.

  • The controller gains durable state — and a PVC — it never had. Phase 0.5 adds a SQLite file on a new PVC, so the process stops being purely reconstructible and starts having a backup/restore story, a schema-migration story, and (on local-path) a node affinity. Mitigation (revised 2026-08-24): two recovery paths, neither the dropped repair subcommand. Cheap rollback if the authoritative flip misbehaves: AGENT_STORE_AUTHORITATIVE=false → mirror mode reconstructs from IRC on boot (the write path to IRC is unchanged, so IRC stays current) — recovers IRC-visible tasks but not store-only data (ask records, aged-out archives). Real DR: the .db file folded into the existing restic-backup-vibes sweep alongside ergo-data, with a restore actually tested before going authoritative. The alternative — leaving the record in a chat server that silently caps, expires, and truncates it — is the larger durability risk, not the smaller one.

  • Dual-write drift during Phase 0.5. Step 2 writes both the store and IRC, so the two can disagree before reads flip over. Mitigation: IRC stays authoritative through step 2; the store is compared against reconstruction, not trusted, until step 3.

  • Attribution regression. If [as <nick>] is not landed, every Slack-filed task reads requester = <proxy> and !tasks mine becomes useless. Mitigation: Phase 2 gates on it; do not ship the API without it.

  • MaxActiveTotal is now shared. Default 3. Slack task volume competes with IRC task volume for the same three slots. Mitigation: size it before Phase 5; ask work deliberately does not consume slots.

  • Phase 0 touches a live path. The provider unpin changes spawn env for every existing task. Mitigation: default to anthropic when unspecified so current behaviour is byte-identical until someone opts in.

  • Our pi-mono pin is a scope rename and ~11 minors behind. We pin @mariozechner/pi-ai / pi-agent-core at 0.73.1 (base/agent-wrapper) and @mariozechner/pi-mom at 0.70.6 (slack/bot/Dockerfile). Upstream is @earendil-works/pi-* at 0.84.2 — a package-scope migration on top of the version gap, so this is a rename-and-bump, not a bump. irc/pi-mono-update-plan.md documents the 0.54 → 0.73 bump and is the template for doing it again; the same blast radius applies (agent-wrapper is a base-chain image under six agents, the IRC worker, and slack/bot). Mitigation: treat it as its own change, before or after this plan but never entangled with it. Nothing in Phase 0 requires the bump — it is Go-side only, and the providers we need predate our pin. The bump is only load-bearing for the lanes-based threading fix, which is deferred anyway.

    Sized 2026-08-22 — smaller than the version gap suggests. Everything the wrapper actually calls survives to 0.84.2: beforeToolCall, toolExecution (still defaulting to "parallel", so the "sequential" override stays necessary), and all five Agent methods in use — subscribe, prompt, waitForIdle, abort, steer. Most breaking changes between 0.73 and 0.84 land on AgentHarness / SessionStorage, which we do not use. Only three items actually apply:

    VersionChangeImpact
    0.74.0package scope → @earendil-works/*mechanical rename across package.json + imports
    0.75.0minimum Node.js 22.19.0base-image requirement — check rrchnm/vibes/base
    0.81.0Agent’s optional streamFn fallback replaced by a required streamFunctionthe one real code change

    Also re-test the esbuild bundle: 0.80.0 removed the /base selective-provider entrypoint and 0.81.0 changed how built-in providers enter bundles, which is the same area as the @opentelemetry/api build gotcha documented in irc/pi-mono-update-plan.md.

  • The review gate spans two systems. for_review is accepted by !complete in IRC; a Slack user filed the work. Mitigation: Block Kit buttons in the originating thread, calling POST /v1/tasks/:id/actions. notifyRequester (bot.go:376) is the trigger, and turnstone proves the interaction works over Socket Mode. Risk downgraded from “unsettled UX” to “build it, copying a known-good pattern.”

  • Slack threads have no IRC equivalent. A task maps to #task-N cleanly; an ask thread maps to nothing, and follow-ups must reach the same warm session. The wrapper keys conversation state by channel_id alone and already collides across concurrent threads — moving dispatch relocates this rather than fixing it. Tactical mitigation: adopt turnstone’s route-key shape — (channel, user_id?, thread_ts?) round-tripped to an opaque id and persisted, so routes survive a restart. Fix it in the same change that moves dispatch; do not carry the bug forward. Principled fix, upstream: pi-agent-core now models exactly this. Its lanes are named cursors into an immutable entry tree, each owning its own leaf, model config, queues, and at most one operation — and the documented worked example is literally a Slack thread (harness.createLane("slack:1719432.0021", at: <leaf>), packages/agent/docs/harness.md §0.4). A lane per thread over one shared channel history gives mutual exclusion by construction, plus crash recovery via the durable op.state register. Verified 2026-08-22: lanes do not exist at our pin. Harness v2 (implement harness v2, promote durable harness API) is contained only in v0.84.0 and later; we are on v0.73.1. It is also very new — 0.84.2 is HEAD, and pi-server/pi-protocol alongside it are marked experimental. Take the tactical route-key fix now; revisit lanes only after the version bump and after that surface has settled.

  • Button state lost on rollout. Approval buttons whose payload carries the correlation data break on every pi-mom restart. Mitigation: static custom_ids with correlation stored out-of-band, per turnstone/channels/slack/bot.py.

  • pi-mom appears orphaned upstream — the most important finding here. Verified 2026-08-22: @mariozechner/pi-mom has no source in either upstream repo. It is absent from the pi monorepo, and pi-chat — which the monorepo README names as the home of “Slack/chat automation” — supports Telegram and Discord only (ChatService = "telegram" | "discord"; no occurrence of “slack” anywhere in the tree). The vendor’s chat story has moved to a pi extension with per-channel Gondolin micro-VMs, and Slack did not come along. Consequences: no visible upstream to contribute a patch to, no upgrade path for the 0.70.6 pin, and a fifth patch-*.js would be a new anchor into the compiled dist/ of an unmaintained package — on top of four existing patches that already carry their own maintenance doc and version-pin ritual.

    Decision: keep pi-mom as-is and proceed. A PR adding a Slack adapter to pi-chat is in flight, so the ecosystem is not abandoning Slack — it is relocating, and there will be a migration target when we want one. Until then pi-mom is ours to change, which is the flexibility it was chosen for. Mitigation: keep the patch count minimal, keep docs/pi-mom-patches.md current, and re-evaluate when pi-chat’s Slack adapter lands (below).

  • Migration target when pi-chat’s Slack adapter lands: pi-chat is Apache-2.0, TypeScript, pi-mono-native, with two small well-defined seams — DiscoveryProvider (validate / fetchSnapshot) and LiveConnection (send, sendImmediate, typing, syncPreview, reply-to, resume cursor). Two things to check when evaluating it, not before: LiveConnection as it stands has no approval or interactivity primitives, so approve/deny may still be ours to build; and pi-chat wants QEMU plus a Gondolin guest image and tmux per connection, which is a serious constraint under k0s (nested virt, /dev/kvm) — though plan-k0s-kata-runtimeclass.md suggests that ground has been surveyed. Direction, not a plan.

Rollback

Phases 0–3 are additive; nothing depends on them until Phase 4. Phase 4 is a revert of patch-tools.js plus restoring worker.py. Phase 5 runs both paths in parallel for a release, so rollback is flipping the capability back to direct POST.

Phase 0.5 rolls back per step: steps 1–2 are observation and dual-write, so reverting is dropping the store. After step 3 — and after the authoritative flip — the rollback is simply AGENT_STORE_AUTHORITATIVE=false: the controller resumes mirror mode and reconstructs from IRC on boot automatically (no repair subcommand needed — that was dropped 2026-08-24), returning to today’s behaviour with today’s ceilings. It recovers only IRC-visible tasks; store-only data (ask records, aged-out archives) comes back solely from the .db restic backup, which is why a tested restore gates the flip.


Open questions

  • Where does a Slack user accept for_review work? Resolved 2026-08-22: in the originating Slack thread, via Block Kit buttons over Socket Mode. See “Slack can carry the full workflow”.
  • Should turnstone’s channel adapter be the Slack front-end? Resolved 2026-08-22: no. pi-mom stays; turnstone is reference only and is not modified by this plan.
  • Does pi-mom’s Socket Mode client handle block_actions payloads, or only message events? The entire review-gate-in-Slack design depends on it, and it is still the one open unknown that could reshape Phase 4. Source is unavailable — pi-mom is in neither upstream repo (see the orphaning risk) — so the only way to answer it is to read the shipped package in the running image: kubectl -n vibes exec deploy/slack-bot-pi-mom -c pi-mom -- grep -rl "block_actions\|interactiv" /usr/local/lib/node_modules/@mariozechner/pi-mom/dist/. Do this before any of Phase 4 is designed. If the answer is no, the options are a fifth patch (no upstream to contribute to) or bringing a Socket Mode client of our own alongside — @slack/bolt supports interactivity over Socket Mode and would not need a public URL.
  • Does the harness support non-Anthropic providers? Resolved 2026-08-22: yes, comprehensively. See Phase 0.
  • Should the wrapper move to pi-server / pi-protocol / pi-client? Upstream has grown a session server since our pin: a CBOR wire protocol, durable SessionMetadata readable without acquiring a runtime, SessionSnapshot for runtime state, exclusive/shared session leases (PiSessionOwnershipError on contention), and a SQLite session backend. That is a principled fix for two things this plan currently works around — the in-memory per-channel state that dies on restart, and the concurrent-thread collision — and it models ask’s warm-session-reattach case directly (listSessions / openSession / attachSession). It does not replace agent-controller: it is a session protocol, not a scheduler, state machine, or pod spawner. The composition would be controller-schedules-and-spawns, pi-server-owns-the-session-inside, replacing the wrapper’s bespoke /prompt endpoint and its Map<string, Agent>. Blocker: pi-server and pi-protocol are marked “Experimental… may change or be removed without notice.” Do not build on them yet; re-evaluate at the version bump.
  • Where does the capability registry live? Resolved 2026-08-22: split out of vibes-slack.toml into a shared registry in Phase 3; Slack keeps its ACL and toggles. See “Splitting the config”.
  • Should accessibility-tester become a task? Resolved 2026-08-22: the question was malformed — work shape is per-request, not per-agent. It supports both.
  • Does dashboard-backend merge into the API process? Both are Go, both hold an ergo connection, one reads and one writes. plan-agent-task-console.md §“Security” leaves the read/write split open for the same reason. Phase 0.5 changes the calculus: with a store, the read model can be a query against it instead of a second firehose subscriber holding its own in-memory reconstruction, which is most of what dashboard-backend currently is. Decide after 0.5, not before.
  • Is the store SQLite or Postgres? Phase 0.5 assumes SQLite on the controller’s existing PVC — no new workload, no new backup story, and the controller is single-writer by design so the concurrency limits do not bite. Postgres only becomes interesting if the API process or dashboard-backend should read the store directly rather than through the controller, which is the open question above.

Decision log

2026-08-26 — unbundle repo into a run-profile + repo hints (additive/sugar)

Arose from a design conversation after the cutover went live: “repo” conflated four things and only one of them is a real controller concern.

The two dispatch shapes, stated plainly (context for the rest): an ask routes to a WARM, standing agent Deployment (a vibes-agent-wrapper on :9000) — synchronous, no pod spawn, ephemeral in-memory state; a task spawns an ON-DEMAND worker pod (a K8s Job + per-task PVC) with a full lifecycle. Only tasks clone repos, so “multi-repo” is entirely a task concern. A capability is the named preset for an ask; a repo is the named preset for a task — same idea, projected differently by each client.

The problem. A repo{name, url, branch, pod_template, secret_name} welds together: (1) a pre-clone target {url, branch}, (2) credentials secret_name, (3) pod shape pod_template, (4) an IRC grouping (#<repo>). The worker is a general agent (gh+git+egress), so (1)/(3)/(4) never limit what it can touch — it can clone any repo. The ONLY real constraint on multi-repo work is (2): the pod holds one secret_name’s tokens, so it can push only where that token reaches. So repo binding is a convenience + a credential/pod boundary, not “the repo the worker works on” — and a repo-less task already proves the binding is optional.

The model (decided: additive/sugar — nothing live breaks):

  • run-profile {name, secret_name, pod_template} — the controller-owned execution/ security boundary: what creds the pod mounts + what shape it runs at. A task requires one (or the controller default). This is the real concept the controller should own.
  • repo hints task.repos = [{url, branch}, …] — the work’s scope, unbounded, a convenience: pre-cloned into the workspace, agent may clone more. NOT a boundary.
  • repo stays as SUGAR: repo popquiz expands to {profile: popquiz, repos: [popquiz's url/branch]}. Live tasks, SEED_REPOS, web-developer-channel-repos.yaml and the #<repo> hub channel all keep working; profile + repos[] are added as the primitives for the multi-repo case. Migration is mechanical — today’s repo already IS a profile welded to one hint.

Scope boundary (calibrated). This makes the model correct and single-credential multi-repo natural (a broad PAT in the profile, repos:[A,B,C], the agent clones+pushes all). It does NOT by itself solve cross-credential multi-repo push — two orgs, two tokens needs the worker to route creds per-remote (a git credential-helper feature). That’s a separate worker-side step; the unbundle just gives it a place to hang (secret_name → secrets[]) later. v1 is single-secret profiles.

Sub-decisions. (a) sugar, not replace — above. (b) The #<repo> hub channel is adapter-only: once the controller emits repo/profile events, the adapter derives the channel from the primary repo hint (or maps to #<profile>) — the controller stops caring. (c) single-secret profiles for v1; multi-secret deferred with cross-cred push.

Status: modeling decided, NOT built. Sequence when picked up: profiles store + /v1/profiles (mirrors capabilities/personas) → task.repos[] + profile on create/spawn (repo-sugar expands to them) → adapter derives the hub channel from the primary hint. Relates to the pending hub_* → repo_* event rename (the events are already repo-shaped/channel-free — only the kind name is IRC-flavored) and the still-open SEED_REPOS-didn’t-persist (“0 repos” after the flip) bug.

2026-08-24 — repair dropped; store rollback is the flag, DR is the backup

Phases 0 and 0.5 are now deployed and verified live (provider unpin proven end to end on both OpenRouter and Anthropic with real PRs; store mirroring + typed :9100 ingest running and drift-clean), so the one open question for 0.5 was what gates flipping AGENT_STORE_AUTHORITATIVE.

The originally-owed repair subcommand — reconstruct the store from IRC’s LIST + METADATA — is dropped. It was a migration-era crutch: there is nothing in IRC worth reseeding from (the “no users” finding), IRC’s copy is lossy anyway (1-week history TTL, and no ask records / transition history / ingest tokens), and rebuilding the primary from a replica perpetuates the two-records model this plan exists to remove.

What replaces it:

  • Rollback = the flag. AGENT_STORE_AUTHORITATIVE=false resumes mirror mode, which already reconstructs from IRC on every boot; the write path to IRC is unchanged, so IRC stays current. No subcommand needed. Recovers IRC-visible tasks only.
  • DR = the .db restic backup. The gate on going authoritative becomes “the .db is in restic-backup-vibes AND a restore has been exercised at least once,” not a repair path. Store-only data (ask records, aged-out archives) comes back solely from the backup.

Do not flip to authoritative with neither a proven backup nor a rollback path — that is the archive-by-omission cliff (a lost node-local local-path PVC and the controller comes up believing every task vanished). This session also cleared the deploy blockers that made the above real rather than theoretical: the vibes/-namespace image-name mismatch that silently defeated auto-pin, and an uppercase-task-id bug that made every pvc-task-<id> an invalid K8s name (so no worker had ever actually spawned). Finding 3 was also closed beyond its original scope — the controller no longer scrapes chat; typed ingest is the sole worker→controller path (hardened with retry/flush, required at boot).

2026-08-23 — Phase 0.5 built end to end; the flip is a flag, not a consequence

All three steps landed. Two choices are worth remembering because both were tempting to get wrong:

The store becoming authoritative is its own config flag, not something AGENT_DB_PATH implies. Deriving it would mean that deploying the code moves the system of record on its own — and against a store that has never been populated, the controller would come up believing every task had vanished and archive by omission. AGENT_STORE_AUTHORITATIVE off is the complete rollback: the reconstruction path is untouched and resumes owning state.

Reconstruction still runs when the store is authoritative, but writes somewhere else. The instinct is to switch it off entirely. Keeping it, pointed at a separate ircView, is what gives the drift check a real left-hand side — otherwise it compares the store to itself and always agrees. The check inverts with the flag, and the log line says which direction it checked, because “they agree” means two different things depending on which side is the record.

The corollary for Phase 1.5: its two stated prerequisites are now met in code. They are not met in production — nothing is deployed — and cutting the largest refactor in this plan against a seam that has never run would be trading one unverified thing for two.

2026-08-23 — the IRC stack has no users; sequencing reordered, Phase 1.5 added

A LIST against ergo returned zero task channels — and since !archive never deregisters, any task ever filed would still have one. No pvc-task-* PVCs either. The IRC agent stack has no users. It is staying as a supported client option, so nothing here removes it, but the original sequencing was shaped by don’t break the running IRC system and there is no running IRC system.

Three things follow, each retiring an earlier decision:

The dual-write evidence period goes. Phase 0.5 step 1 existed to prove the store agreed with IRC before reads flipped. With zero tasks there is nothing to compare. Steps 1 and 2 merge; the mirroring code stays, the waiting goes. The drift check survives with a changed job — no longer a gate, now a regression alarm on the projection.

Decoupling IRC from the controller becomes a phase, and an early one. The plan’s own three-layer diagram claims a client layer, but the controller is an IRC client — 1,561 lines with 76 b.irc.* sites and 18 !command cases fused to the scheduler. That was never written down as work. It is at its cheapest now: the refactor’s risk was almost entirely “breaks production,” and there is no production. Every task filed from here raises the price.

Phase 2 is contingent on it, not settled. The API-as-proxy design — HTTP translated into !commands typed at a chat server — is what you build when the controller has no typed inbound surface. Decoupling and Phase 2 are competing answers to the same question, and the original plan picked the one preserving IRC’s privilege. With no IRC users that is hard to defend, so Phase 1.5 must be settled first. Attribution ([as <nick>]) survives regardless and remains the thing Phase 2 cannot ship without.

Recorded as a revised execution-order table rather than a renumbering, so existing cross-references from plan-agent-task-console.md and plan-agent-workspace-renderer.md still resolve.

2026-08-23 — IRC is the substrate, not the record; Phase 0.5 added

A review asked whether IRC is a sensible substrate for task orchestration. The answer that survived contact with the source: IRC is a good transport and viewing surface and a poor system of record, and this plan had already reached that conclusion without stating it — it puts a typed API in front, records “IRC is better understood as the substrate than the API,” and specifies an ask path with no channel at all.

Everything this plan praises about “the IRC stack” — a deterministic Go dispatcher, no LLM in the dispatch decision, ephemeral pods, one write authority, a typed event vocabulary — is a property of agent-controller, not of IRC. What IRC supplies and should keep is the multi-client surface, the human-readable transcript, and pub/sub. What it supplies and should not is the database.

Four source findings made that concrete (full evidence in Phase 0.5): a 15-channel registration cap the controller silently exceeds because it reads no NOTICEs, a one-week history TTL, a firehose regex-scraped out of chat prose, and firehose events over 400 bytes splitting into invalid JSON that the reader drops without logging. Finding 3 is the structural one — the no-new-string-markers rule adopted on 2026-08-22 is unenforceable while a worker’s only path to the controller is typing into a channel.

Added Phase 0.5: SQLite on the controller’s PVC, IRC demoted to a projection, and a typed worker→controller ingress carrying the existing firehose vocabulary. Numbered 0.5 rather than 1 because it is independent of Phase 0 and can land in either order; Phase 0 is breadth, this is the foundation Phases 2–5 build on. The cost is retiring “the channel is canonical” — recorded as an honest cost alongside the API’s, on the grounds that this plan already breaks the weaker form of the same invariant.

Readiness checked against the live cluster, same day. Both phases can start. Phase 0 is unblocked outright, and its secret wiring turned out to be a kv patch rather than a manifest change, with an OpenRouter key already in the org. Phase 0.5 corrected one error in its own first draft: it claimed SQLite would live on “the controller’s existing PVC,” and the controller has no PVC — it runs readOnlyRootFilesystem: true and writes nothing. The phase creates one. Offsetting that, replicas: 1 + strategy: Recreate are already in place, so the single-writer constraint SQLite needs is already the controller’s design rather than an argument to win.

Explicitly unchanged by this: the 2026-08-22 rejection of FIPA-lite stands. Phase 0.5 introduces no new message schema and no new vocabulary — it gives the firehose contract a durable home and a typed ingress. The instinct behind FIPA was right; the answer is still the firehose, not speech acts.

2026-08-22 — the split already exists; make Slack a client of it

Comparing the three orchestrators (munder-difflin’s god agent, pi-mom, agent-controller) made it clear that the IRC stack already draws the Go/TypeScript boundary in the right place — deterministic dispatcher, LLM-only harness, ephemeral pods, IRC as record. The work is not to design an architecture but to stop running a second one.

2026-08-22 — typed API over IRC-speaking clients

Chose a typed HTTP API over having every client speak IRC as a privileged proxy. Accepts breaking the “all control happens via IRC” invariant, on the grounds that IRC is better understood as the substrate than the API. Mitigated by putting the listener in a separate, RBAC-less process — reusing the agent-controller reaper subcommand pattern — so the k8s-privileged process keeps its “no inbound listener” property.

2026-08-22 — provider unpinning promoted to Phase 0

Originally scoped as a follow-up. Promoted to prerequisite on discovering that models.go, spawn.go:248 and spawn.go:250 pin the worker runtime to Anthropic: every other phase moves work onto that runtime, so migrating first would mean migrating onto a narrower LLM surface than we are leaving — the opposite of the goal.

2026-08-22 — turnstone resolves the review-gate and thread-routing questions

turnstone/channels/slack/ serves interactive Block Kit approvals over AsyncSocketModeHandler — so approve/deny and for_review accept/reopen need no inbound port and no public Request URL. It also supplies proven answers to two things this plan had listed as unresolved: a persisted (channel, user_id?, thread_ts?) route key (the thread-collision fix) and a channel_users link table with a self-service /link modal (the attribution prerequisite). Adopted as design precedent; whether turnstone should also be the implementation is now an open question rather than an assumption.

2026-08-22 — pi-mom stays; turnstone is reference, not implementation

Considered replacing pi-mom with turnstone’s channel adapter, which already ships the approvals, identity linking, and persisted thread routes this plan needs. Rejected: the current Slack implementation is ours to change and adapt — four patches deep and proven malleable — whereas adopting turnstone’s adapter would mean bending to a third orchestration model (Python, its own workstream abstraction) against the stated pi-mono/TypeScript preference. turnstone is untouched by this plan and cited only as a working precedent. The cost of the choice is that the interactive mechanics get reimplemented in TypeScript rather than inherited — and that pi-mom’s Socket Mode client must be confirmed to handle block_actions, now an open question.

2026-08-22 — rejected FIPA-lite speech acts; standardize on the firehose instead

Considered adopting munder-difflin’s FIPA-lite message schema for the sake of using a standard. Rejected: its hops/requires_reply machinery exists to stop peer agents looping, and this design has no peer-to-peer messaging; FIPA-ACL has been dormant since the mid-2000s, so it offers no interop or tooling. The underlying instinct was sound, though — the in-channel string markers are accreting and unversioned. Adopted the rule that new signals become typed #firehose event kinds, not new markers.

2026-08-22 — verified against the pi monorepo; Phase 0 shrinks

/operator/repos/pi confirmed that pi-ai ships ~40 providers including openai, openrouter, and azure-openai-responses (“Azure OpenAI” — MS Foundry). The provider-agnosticism this plan is built toward already exists in the harness; the Anthropic pin is ours alone and lives entirely in agent-controller’s Go. Phase 0 is therefore three Go edits plus secret wiring, with no TypeScript change and no dependency bump — smaller and lower-risk than first written.

Two things surfaced alongside it. Upstream has rescoped to @earendil-works/pi-* and moved to 0.84.2 against our 0.73.1/0.70.6 pins, which makes the next dependency update a rename-and-bump; recorded as a risk, explicitly not a prerequisite. And upstream has grown pi-server/pi-protocol/pi-client plus a SQLite session backend, which would principledly fix the wrapper’s in-memory state and thread collisions — but both packages are marked experimental, so it is an open question rather than a phase.

2026-08-22 — pi-mom is orphaned upstream; pi-chat dropped Slack

Cloning earendil-works/pi-chat to answer the block_actions question instead answered a bigger one: pi-chat bridges Telegram and Discord only — “slack” does not appear anywhere in its tree — and @mariozechner/pi-mom has no source in either upstream repo. The vendor’s chat direction is a pi extension with per-channel Gondolin micro-VMs, and Slack did not come along.

This does not reverse the decision to keep pi-mom (it is still ours to change, which is why it was chosen), but it changes what that costs: no upstream to contribute to, no upgrade path for the 0.70.6 pin, and any interactivity patch lands in the compiled dist/ of an unmaintained package. Recorded as the plan’s most significant risk, with pi-chat’s DiscoveryProvider / LiveConnection seams noted as an escape hatch — heavily qualified, since that route supplies no interactivity primitives and drags in QEMU-per-channel.

The same search turned up the constructive half: pi-agent-core’s lanes model, whose documented worked example is a Slack thread. A lane per thread over shared history, with at most one operation per lane, is a better answer to the concurrent- thread collision than the route-key triple this plan had borrowed from turnstone — conditional on lanes existing at our pinned version.

2026-08-22 — pre-implementation verification pass

Worked the plan’s flagged unknowns against /operator/repos/pi. Three resolved, one outstanding.

Resolved. (1) The harness is already provider-agnostic — pi-ai ships ~40 providers including openai, openrouter, and azure-openai-responses; Phase 0 is Go-only. (2) Lanes do not exist at our 0.73.1 pin — harness v2 is v0.84.0+ — so the threading fix stays tactical and the bump is not on this plan’s critical path. (3) The 0.73.1 → 0.84.2 bump is far smaller than the version gap implies: everything the wrapper calls survives, and only three items apply (scope rename at 0.74.0, Node ≥22.19.0 at 0.75.0, required streamFunction at 0.81.0).

Outstanding. pi-mom’s block_actions support, answerable only against the running pod. Everything through Phase 3 can start without it.

Also settled by decision rather than evidence: pi-mom stays as-is. The orphaning finding is real but not disqualifying, given a Slack adapter PR in flight for pi-chat.

2026-08-22 — work shape is per-request; shared tier gets promoted

Two corrections from review. First, the plan had bound each capability to one work shape and asked whether accessibility-tester should “become a task” — a malformed question. A capability declares which shapes it supports; the caller picks per request. An a11y audit is a task (minutes of work, a durable report, a for_review that means “a human read the findings”); a single-page spot-check is an ask. The registry now expresses shapes as a list.

Second, the tree still encodes the two-stack split this plan removes. Added a target layout promoting agent-controller, dashboard-backend, the renderers, the capability registry and the event schema into a shared tier — extending the pattern base/ and agents/ already establish — with slack/bot, channel-tailer, relay-client and ergo config correctly staying client-specific. Config splits on “would an IRC-only deployment need this?”: capability definitions shared, channel scoping per client. Surfaced one duplication to reconcile rather than sort — web-developer-channel-repos.yaml and IRC’s !hub/!repo are the same repo-binding concept implemented twice. Directory moves are explicitly sequenced to ride the change that makes them true, never as a standalone reorg.


Last updated: 2026-08-24