Extracting the daemon
Close the laptop. Reopen later. Reconnect with whichever surface is at hand. The graph is still there. The agents are still running.
Why should closing my laptop kill the agents I have running? That one sentence above was the whole motivation. The implementation that makes it true landed as ADR-015 — Daemon Process Model on 2026-03-18. This post is the design walk and the trade-offs that came with it.
Context
For the first eight days, the moitumi core lived inside the moitumi
launcher process. TUI controller, graph, agent orchestrator, audit
log — all one process. Quit the TUI and you lose everything in memory.
Restart it and reload from disk. Fine for a prototype… hostile to
how I actually wanted to use the thing.
The actual use case has three pressures pulling on it:
- Multiple surfaces. TUI today; macOS Metal next; iOS after that. They can’t share an in-memory graph if they live in different processes (or different machines).
- Surface lifetime ≠ work lifetime. Agents take minutes to hours. The TUI is a terminal window I’ll close ten times in that span.
- Reconnection is the common case. SSH disconnects, laptops close,
tmux detachhappens. None of that should kill the graph or the agents.
So the architectural answer is the standard one — pull the core out into a daemon, let surfaces connect as clients. Nothing novel here. The interesting part is how to get there without trashing what already works.
The shape
graph TB
subgraph DaemonProcess["moitumid (daemon process)"]
Cortex[Cortex]
Store[CortexStore]
AO[AgentOrchestrator]
AL[AuditLog]
Agents[agent processes]
CP[ControlProtocol]
end
subgraph TUI["TUI surface"]
T_Pres[Presenter<br/>IPresenter adapter]
T_Render[Layout/rendering]
T_Input[User input]
end
subgraph Mac["macOS surface"]
M_Pres[Presenter]
M_Render[Metal renderer]
end
DaemonProcess <-- "Unix socket<br/>(ITransport port)" --> TUI
DaemonProcess <-- "Unix socket" --> Mac
style DaemonProcess fill:#fff4e6,stroke:#d97706
style TUI fill:#e1f5ff,stroke:#0366d6
style Mac fill:#e1f5ff,stroke:#0366d6
The daemon owns: cortex, cortex store, agent orchestrator, audit log,
the agent processes themselves. Surfaces own: rendering, input, local
view state. Transport is a Unix domain socket today, hidden behind a
port (ITransport) so a TCP adapter can slot in later for remote
access — without touching daemon or surface code.
The protocol is the existing interface contracts
To be honest, the single best decision of this refactor was not designing a wire protocol.
Two interfaces already existed in moitumi’s domain:
IPresenter— what the core pushes to surfaces:sync_graph(snapshot),focus_node(id),enter_node(id),node_updated(id),node_removed(id),agent_status_changed(...).ControlProtocol— what surfaces send back:focus(id),enter(id),spawn_agent(id), etc.
The wire protocol is these interfaces, serialized. That’s it. The
daemon implements IPresenter via a DaemonPresenter adapter that
serializes calls and broadcasts to every connected client. Surfaces
implement ControlProtocol via a SurfaceClient that serializes
commands and writes them to the socket. The codec (IProtocolCodec)
is JSON today; protobuf or msgpack slots in as another adapter when
the day comes.
Transport: 4-byte big-endian length + JSON payload (Unix domain socket)
Surface → Daemon: ControlProtocol commands, each carrying _req_id
Daemon → Surface: Responses (matched by _req_id) + push events (IPresenter, no _req_id)
The single-character convention _req_id (underscore-prefixed) avoids
collision with any domain-level id field in the payload. Push
events don’t carry a _req_id at all, so the surface’s event loop
demuxes by asking “does this message have a _req_id? if yes, it’s
a response; if no, it’s a push.” Effectively a free demux scheme.
And that’s the entire wire-protocol design. There was no new protocol
to invent — the work was to serialize an interface that already
existed. Hexagonal architecture earned its keep on this one. (See the
previous post for why
IPresenter was a port from day one.)
Build sequencing: don’t throw the prototype away
The TUI worked. I wasn’t going to ship a regressed TUI just to get a daemon out the door. So the compromise was a phased build:
- Define the IPC protocol against the existing interfaces.
- Build the daemon-mode TUI client against an in-process fake daemon — a protocol handler that runs in-process, no socket. The TUI exercises the full protocol path; the daemon is just a stand-in.
- When the real daemon lands, swap the transport. Nothing thrown away.
moitumikeeps working in embedded mode by default. The daemon is opt-in (moitumi --daemon). When the user runs--daemonandmoitumidisn’t listening on the socket, the launcher forks/setsid/execs the daemon itself, then connects. The user never thinks about it.
The single line of code that made the embedded path keep working was a nullable pointer change in the controller:
// Before
class Controller {
ITmuxPaneManager& pane_mgr_;
};
// After
class Controller {
ITmuxPaneManager* pane_mgr_;
bool daemon_mode_;
// Every pane_mgr_ call now guarded: if (pane_mgr_) pane_mgr_->method();
};
In daemon mode, pane_mgr_ is null — the daemon has no tmux. In
embedded mode, it’s the real TmuxPaneManager. The Controller carries
both worlds without branching all over the place. Refreshing how
little code it took.
Why Unix sockets, not shared memory
Shared memory was on the table. It’s faster (~ns vs ~µs per message for the small payloads moitumi exchanges). However, I rejected it:
- Synchronization is a tax. Mutexes, ring buffers, memory fencing, ABI versioning, alignment. The complexity is real and the benefit is marginal for moitumi’s traffic volume (low hundreds of events per second, peak).
- Unix domain sockets are ~2-5µs latency and ~500K msg/sec. Already more headroom than this system is ever going to use.
- Sockets are the same shape as TCP. The transport adapter swaps for remote access without changing the daemon or surface protocol code. Shared memory would never make that leap.
For the daemon shape, this is the right cost-benefit. Maybe I am biased toward “boring tech wins” but… if the embedder ever arrives and starts pushing millions of vectors per second, the conversation re-opens. Until then, sockets.
What didn’t make it (and why)
Push events aren’t acted on in daemon-mode TUI yet. When the daemon
sends node_updated or agent_status_changed, the surface logs them
but doesn’t translate to tmux layout operations. The plumbing’s there
— SurfaceClient exposes an event callback — but the translation
layer isn’t. Deliberately deferred; the surface protocol was the
risky piece, the translation is mechanical.
Daemon-native agents aren’t there yet either. Agents in the TUI
work because TmuxAgentManager spawns Claude Code in tmux panes. The
daemon has no tmux. A daemon-native executor (fork/exec with pipe
I/O, process supervision) needs to replace it. The placeholder is a
NullAgentManager that satisfies the port and rejects every spawn
request. Needless to say, agents in daemon mode are not available
until that adapter lands.
Single-machine only. Unix domain sockets are local. TCP slots in
behind ITransport without daemon changes; multi-machine cortex sync
(Obsidian-Sync style) is a separate concern at the CortexStore
level.
Auto-reconnect is mandatory
One non-negotiable: all surfaces auto-reconnect. No “reconnect” button. No “daemon disconnected” modal. Reconnection is silent, automatic, with retry-backoff. On reconnect, the surface re-syncs state from the daemon (re-register, re-fetch tree, restore view).
That being said, this is purely a transport-level concern. It belongs
in the ITransport adapter or a thin connection-manager wrapping it.
The application code above doesn’t see disconnect at all unless the
operator explicitly asks.
If reconnection ever feels visible, the daemon abstraction has leaked. The goal of the daemon is invisibility — you forget it’s there. At least, that’s what I felt like when I drew the line.
What this earns
- State outlives surfaces. Close the laptop, reopen with
moitumi --daemon, the graph and any in-flight agents are still there. - Multiple surfaces, same state. TUI and macOS viewing the same cortex simultaneously — same node updates flow to both.
- Remote work. SSH into a dev box, attach a local surface to a daemon there (with the future TCP adapter). The daemon is the source of truth; the surface is wherever I happen to be sitting.
- Agent isolation. Agents are subprocesses of the daemon. Killing the surface doesn’t kill the agent’s work.
Overall, the architecture earns its keep the moment the user stops
thinking about the architecture. Two days after the daemon landed,
I closed the laptop with three agents running. Reopened it the next
morning, ran moitumi --daemon. Three agents still running, two with
results to review. That’s the win.
Source pointers
DEVLOG.md— entries of2026-03-18(“Daemon extraction: moitumid as standalone process with surface clients”)PLAN.md— ADR-015 (Daemon Process Model)docs/architecture/system-overview.md— daemon/surface boundary diagram
Cross-posted from the moitumi dev blog.