Login
Architecture
Login

The managerial-level map of how this system fits together. Definitions here are the single source of truth — TODO.md, BACKLOG.md, tickets, and notes link to these anchors instead of re-explaining. Renders with diagrams via Fossil at /doc/trunk/docs/architecture.md.

Why this project exists

It started to get codex working with a local ollama model and actually do useful work — codex struggled against a local model, and understanding why drove most of the design. The goal grew into: drive coding/chat work through a Fossil web UI, with the model being local Ollama or a hosted provider, behind one consistent tool layer.

Axioms

The three anti-failure axioms — Clarity, Interchangeability, and Accountability — are defined in README.md ("Why this exists"). That is the source of truth; this is a pointer, not a restatement.

They are not tasks and never "done" — they are how we judge whether the workbench is getting more robust or drifting toward failure. The seams and definitions below exist to serve them: this whole document is Clarity; the Model/Provider/AgentRunner seam and the interop boundaries are Interchangeability made concrete; the verify/report/evidence discipline is Accountability.

Vocabulary

The terms that kept getting conflated. Defined once, here.

AGENT_CODEX is exactly the conflated, model-coined term these definitions retire: it merges the Codex product with "HTTP provider." It is replaced by an explicit AgentRunner (codex) + a Provider (the model endpoint) + derived Capability.

Conversation: Fossil /chat is the surface

The conversation lives in Fossil's own /chat — a single chatroom persisted in the repository database. There is no bespoke conversation store and no files on disk for it. This retires the file-based ThreadStore (a Thread/Turn model we invented) and the legacy SavedChat transcript store; both are being removed. The win is Clarity and using Fossil's native persistence instead of our own.

The agent's authority is split by what it may touch, and the split is enforced structurally by which tools it holds:

So a request in /chat is answered or acted on inline when it is non-code, and routed to the Task pipeline when it is code — and that boundary holds because the chat agent simply lacks the tools to do otherwise.

Forum-backed sessions

/chat is deliberately ephemeral — one global room, quick exchanges, no memory. A long-lived, resumable session is a different shape, and Fossil's /forum is its natural home: durable, threaded, one topic per thread. This is the concrete form of the AgentRunner session the milestone is about.

A forum thread holds a whole milestone's worth of work, interactively — one sustained collaboration in which a human and a capable agent reason together over time and spawn the many coding Tasks a milestone needs. It is not a wrapper around a single ticket. (The working prototype is exactly the kind of long, interactive, milestone-spanning session you have in a Claude terminal — many decisions, a dozen commits, one line of thought.)

**A forum thread is bound 1:1 to one AgentRunner + one session + one checkout.** One runner per thread is a hard rule — it is what makes the thread a cohesive line of thought (compaction may shorten it, but it stays one thread of reasoning).

The session brain is runner-pluggable — and aspires to be more than one option. The premium, key-free path is the claude runner driven via claude -p --resume <resume_id> on the Pro login (sanctioned; no API key) — sustained Claude-grade reasoning resumable across days, with resume_id holding the CLI's session id. (Spike-verified 2026-06-06: a fact set under --session-id <uuid> was recalled by a separate --resume <uuid> process, headless, on the Pro login; --output-format json returns result + session_id. Pattern: own a uuid → --session-id on turn 1, --resume after.) But the aspiration is explicitly not Claude-only: that other runners approach that functionality — the native local model pushed further with consult runs that lean on Claude/ChatGPT for the higher-order reasoning minimally and on budget (the project's reason for being), plus RAG-style context awareness to compensate for its smaller context window. Claude is one brain; the goal is a capable, cost-efficient local-driven session that consults the pro models only when it must.

Three conversation surfaces, not two

The forum is the durable log, not the real-time channel. Keep them distinct:

Identity, binding, and storage

Session checkout lifecycle

Most sessions own one long-lived checkout on a session/<root-hash> branch (workspace: "session"). Unlike a one-shot Task (fresh checkout, discarded after), it persists — but it is still isolated from /root/ollama; only its lifetime differs. The exception is a primary-tree session (workspace: "primary", the trunk-ops runner), which binds to the live /root/ollama on trunk and exists to operate on the repository — so it has no isolated checkout by design.

One binary, several interfaces

There is a single compiled artifact, bin/ollama-codex, containing all native tools. Everything else is an interface onto that one tool collection — the same binary via argv0/mode dispatch and compatibility symlinks.

ext/agent   ext/docs   agent-tool   task-worker   mcp-server ( interfaces ) bin/ollama-codex one binary — all native tools
box "ext/agent   ext/docs   agent-tool   task-worker   mcp-server" "( interfaces )" fit
arrow
box "bin/ollama-codex" "one binary — all native tools" fit fill 0xeeeeee

Deploy discipline. The served CGI binary (/var/www/cgi-bin/ollama-codex) is a symlink to bin/ollama-codex, and the CGI spawns fresh per request — so for the CGI a rebuild is the deploy. The persistent -d daemon is different: it holds its compiled binary in memory and does NOT auto-update. Because its agents call tools in-process (not via the auto-rebuilt CLI), any deploy that changes daemon-run code requires an explicit rebuild then restart (task-worker --stop && task-worker -d) — never assume the daemon picks up a rebuild on its own.

Language-generic vs project-specific content

The same binary drives any Crystal+Fossil project (see the active milestone in TODO.md) — so every piece of data it carries has to declare which side of that line it sits on:

agent-tool check-project prints both halves of this boundary (project_state_root, project_ephemeral_root, runners_config_key, etc.) so an operator can see what a re-pointed binary will resolve.

The Model / Provider / AgentRunner seam

Task / Chat AgentRunner strategy + session + tools Model via Provider
box "Task / Chat" fit
arrow
box "AgentRunner" "strategy + session + tools" fit
arrow
box "Model" "via Provider" fit

A Task or Chat is handed to an AgentRunner. The runner picks a strategy, opens or loads a session, and drives a Model — reached through a Provider — using its tool set, until it produces a report (Task) or a reply (Chat). Swapping the runner (native / codex / claude / zeroclaw) or the provider (ollama / openai / anthropic / litellm) is a substitution at this one seam — Interchangeability made concrete. Running the same Task through two runners and comparing the evidence is Accountability at work.

The native runner's model call goes through ProviderChat.complete — one completion against the runner's Provider. So any Provider × the native runner holds for sessions and /chat, not just tasks — a native session can run on local Ollama or a remote model (e.g. OpenAI gpt-4o) through the same in-process tool loop. (Earlier, a native session was hardcoded to local Ollama and a remote provider's model name 404'd against it.)

The Provider owns its wire protocol

How to talk to an endpoint is the Provider's concern, not each caller's. A ProviderProtocol (one per api style — OllamaProtocol / OpenAIProtocol / AnthropicProtocol) is the single authority on a provider's wire format: auth headers, endpoint paths, request-body generation, and response parsing (chat text, thinking, usage, embeddings). ProviderConfig#protocol selects it (Ollama by endpoint, the rest by api style), and the façade — config.chat(messages, opts, repository:) and config.embed(input, repository:) — is the one door callers use; a single ProviderHTTP.post is the only HTTP seam. Consumers (HTTPModelProvider, ProviderChat, the tool-loop model call, the connectivity test, VecEmbedder) never branch on api style — they build a request via the protocol, post it, and parse via the protocol. Embeddings are genuinely multi-provider (Ollama /api/embed, OpenAI /v1/embeddings; Anthropic has no native endpoint and raises). This replaced the old shape where every caller pulled raw ProviderConfig fields and reconstructed the interface from data.

Interop boundaries

Every Process.run is a point where we hand a role to an external tool — a seam we could swap, test, or reimplement. bin/agent-tool interop-points monitors them: it classifies each call site by role (scm, crystal_toolchain, agent_runner, sandbox, archive, …) and splits two buckets:

Roles worth naming as first-class interfaces: Scm (fossil), CrystalToolchain (crystal), AgentRunner (the loop), Sandbox (timeout). Note: leaky means candidate, not "must route" — a site is only safe to route once the native tool preserves the call site's behavior.