Skip to main content

One daemon, one agent, two thin clients

Status: Planned — implementation in progress Priority: P1 Related: #2142 (daemon agent control plane), #2829 (session retention), Slack via the TUI

1. The finding

GAIA already has two ways to run the flagship agent, and is about to grow a third. The TUI spawns it as a stdio child. The daemon knows how to supervise it as an HTTP sidecar — the spec, the packaging entry, and the routes all exist and are unused. The Slack bridge, as merged, spawns a second stdio child of its own. Every one of those is a separate place for the agent to be started, configured, supervised, and to go wrong. This plan collapses them to one: the daemon supervises exactly one flagship agent, and every client is a thin caller of it. The payoff is subtraction. Convergence does not add a layer — it deletes the reasons three other things had to exist.

2. What already exists

The daemon path is far closer to done than its disuse suggests. Nothing in this table is new work: What is missing is one line of catalog and three defects. The flagship declares TransportSubprocess at catalog.go:570; the rest of this document is the three things that have to be true before flipping it is honest.

3. The three defects

These are not “nice to have first”. Each one is a feature the stdio path has and the HTTP path silently lacks, and shipping the flip without them is a regression dressed as architecture.

3.1 gaia-agent --serve does not work

gaia_agent/server.py:main() is a dual-transport dispatcher — --serve/--host/--port runs HTTP, anything else runs stdio. But the console script points straight past it:
So a pip-installed gaia-agent --serve exits with unrecognized arguments: --serve. The frozen binary and the daemon’s dev mode both reach the dispatcher by module path, which is why nobody has hit this — but it means the documented transport is unreachable from the package that documents it. Fix: point the script at gaia_agent.server:main. One line, plus a test that runs the real entry point with --serve rather than importing main directly — importing is what would have kept passing through this bug.

3.2 Claude is refused by the HTTP surface

/query already accepts model and provider, and rejects anything but Lemonade with a clear 400. The stdio path meanwhile takes --use-claude / --claude-model, and the TUI’s factory refuses the daemon transport for exactly this reason:
agent %q runs over the daemon transport, which cannot switch inference backends
Fix: allow claude, thread provider and the Claude model id into agent construction, and keep the refusal for anything genuinely unsupported. The error that exists today is good — it should become narrower, not disappear.

3.3 A live session cannot switch model, it can only be abandoned

The HTTP path already refuses this loudly — server.py:560 returns a 409 saying a model cannot be switched on a live session and to start a new session_id. That is honest, and it is not the silent-drop bug it first looks like. The gap is narrower and more annoying: stdio.run_model_command swaps the LLM client in place on the same agent, which is precisely what keeps conversation_history and loaded_skills alive across a switch — the module’s own docstring calls that out as the reason a child respawn would destroy them. The HTTP path has no equivalent, so its only honest answer is “start over”. Fix: give the retained session a live switch that reuses stdio’s existing machinery (_apply_switch / _apply_claude_switch / _apply_local_switch — already written, already all-or-nothing with a snapshot/rollback), and replace the 409 with an actual switch. The refusal stays for anything that genuinely cannot be applied to a built agent; it just stops covering the one case the user actually asks for. Reusing that machinery rather than writing a second switch is the point. Two implementations of “move an agent onto a new client” is exactly the duplication this plan exists to remove.

3.4 The daemon transport cannot carry a tool approval at all

This is the largest gap and the plan originally missed it. It is also the one that matters most, because it is the safety path: the flagship gates shell, file writes and code execution behind a confirmation, and over the daemon that confirmation currently has nowhere to go. Three things have to be true for a gated tool to work, and none of them is: The TUI degrades honestly rather than dangerously — respondToolPermission returns “this agent connection cannot deliver a permission decision” — but the practical result of flipping the transport today is that every gated tool on the flagship would hang until it timed out and auto-denied. Shell and file writes would simply stop working. Fix: a tool-decision endpoint alongside /respond, carrying confirm_id and the three decisions the stdio control channel already accepts (allow / deny / always), plus a bypass endpoint; then SSEClient implements both interfaces. The agent side already has the receiving machinery — PermissionState.resolve and set_bypass in stdio.py — so this is plumbing an existing capability onto a second transport, not inventing one. This is also the piece the Slack bridge needs. Its Block Kit buttons currently reach the stdio control channel directly; once it becomes a daemon client they go through the same endpoint the TUI uses, which is the point of converging.

4. The convergence

Once §3 is true, the flip is small and mostly deletion.

4.1 The TUI

Flip catalog.go’s flagship entry to TransportDaemon and drop BinaryPath. The factory already builds an SSEClient for that case, and the readiness ladder in ui/preflight/ already diagnoses daemon failures — it was written for the email agent and is deliberately generic.

4.2 Slack becomes a thin client

messaging/bridge.py’s AgentChannel stops owning a child process and becomes a caller of the same relay the TUI uses. This is where convergence pays for itself — it removes three things the merged Slack bridge had to carry: A Slack thread becomes a session_id, so two people — or one person in two threads — hold separate conversations against one warm agent. That was listed as a limitation of the Slack work; it falls out of convergence rather than being built.

4.3 What is NOT deleted

gaia_agent/stdio.py stays. It is the agent package’s own supported transport, the frozen binary dispatches to it, and an integrator embedding the agent without a daemon needs it. What goes away is GAIA’s use of it as a second way to run the flagship — not the module.

4.4 What the transport flip actually costs — measured, not guessed

The flip was tried and reverted, because it is not the one-line change §2 implies. Flipping catalog.go’s flagship entry to TransportDaemon compiles and breaks two packages: --mock is the blocker, and it is a user-facing flag, persistent on the TUI root. It is also the entire test-double strategy: there is no way to drive a daemon-transport agent in a test today without standing up a fake daemon. The way through is known — tui/test/fakedaemon_test.go already serves the daemon’s control plane against an isolated GAIA_DAEMON_HOME, so extending it with /ensure, /version and a streaming /v1/gaia/query gives the e2e suite a daemon-shaped double. But that is a test- infrastructure project plus a change to what --mock means, not a catalog edit, and it touches the path every user hits on launch. So it is sequenced last, on its own, where it can be verified against a real daemon and a real sidecar. Everything before it stands alone and is what makes it possible.

5. Scope discipline

The instruction behind this plan was one flow, minimal scope, minimal bugs. So, explicitly out of scope:
  • No new transports, flags, or fallback paths. If the daemon is not running, the TUI says so and offers to start it — it does not quietly spawn a child instead. A fallback here would recreate the second path this plan exists to delete.
  • No behaviour changes to the agent loop, prompts, or tools. Nothing here should move an eval score; if it does, something was changed that should not have been.
  • No Slack feature work. The bridge’s surface stays exactly as merged; only what is behind it changes.
  • No daemon feature work. The broker, custody, and scheduler are used as they are.

6. Order of work

Strictly sequential — each step is independently verifiable, and none of them leaves the tree in a state where two paths are both live. Step 6 is a gate, not a formality. Agent construction is ~42s: today the TUI pays it at launch, and after this the first turn after daemon start pays it. That is probably a wash and may be better, since the daemon outlives any one terminal — but it must be measured rather than assumed, and the number belongs in the PR.

7. Testing

  • Contract, not invocation. Step 1’s test runs the real console script; steps 2-3 assert against the agent’s own request model and registry rather than a mock that would accept any keyword.
  • The regression each defect names. A test that /model on a live session actually changes the model, and that conflicting config raises instead of being dropped.
  • Both clients against one agent. An integration test that drives a TUI-shaped session and a Slack-shaped session concurrently through the relay and asserts they do not see each other’s history.
  • Evals unchanged. This plan touches transport, not prompts or tools, so gaia eval agent should be flat. Running it is how that claim gets checked rather than asserted.