Session Lifecycle
This page traces what happens from catenary invocation through
shutdown.
Startup sequence
A single daemon serves the whole host. The bridge proxy — catenary
launched over stdio by an MCP client — starts the daemon on first
connection and then just forwards bytes; later agents reuse the same
daemon. Daemon startup, in order:
-
Socket bind. The daemon binds two deterministic Unix sockets under
state_dir:catenary/catenary-mcp.sock(MCP traffic from the bridge proxy) andcatenary/catenary.sock(hook events and CLI commands). Binding first proves this is the sole daemon and lets bridge proxies queue connections during the rest of init. Any leftover legacycatenary.dbis deleted here. -
LoggingServeractivation. Constructed earlier in buffering mode (earlytracingevents are captured in a bounded 4096-event buffer); once the sinks exist,activate()drains the buffer and switches to direct dispatch. The sinks are the JSONL firehose, the desktop-notification sink, and the daemonstate.jsonsnapshot. -
Config loading.
Config::load()reads sources in order: embedded default language definitions, user config (~/.config/catenary/config.toml), and an optional explicit file (CATENARY_CONFIGenv var). Later sources override earlier ones; environment overrides (CATENARY_SERVERS,CATENARY_ROOTS) are applied last. -
Root resolution. Workspace roots come from
CATENARY_ROOTS(path-separated) or default to the current directory. Roots are canonicalized to absolute paths. -
Primary session assembly. The daemon builds one shared, infrastructure-only
Session. It owns the resources every agent shares: theLspClientManager(the LSP server pool), the tool servers (GrepServer,GlobServer,DiagnosticsServer), theSymbolIndex(populated lazily from LSPdocumentSymbolresponses), and the JSONL firehose sink.FilesystemManageris constructed with classification tables from config and the roots are set — there is no filesystem snapshot at startup; change detection walks on demand, and each root’s first walk is its own baseline. This session has no connection affinity. -
spawn_all. The client manager walks workspace roots, classifies files viaFilesystemManager, detects which configured languages have matching files, and spawns LSP servers:- Project configs (
.catenary.toml) are loaded for each root. - Per-root classification tables are set.
- For each detected language, each configured server binding is spawned. The first root triggers the initial spawn; the server’s capability response determines scope.
- Workspace-capable servers get a single
Scope::Workspaceinstance with all roots. Legacy servers get a separateScope::Rootinstance per root. - Project-scoped roots (those with a
.catenary.tomlthat overrides the language’s server config) get their ownScope::Rootinstance and are excluded from the workspace instance viadidChangeWorkspaceFolders.
- Project configs (
-
Accept loop.
SessionManagerbegins accepting connections on both sockets. The daemon never reads stdin — the bridge proxy owns the stdio pipe to the host CLI and forwards MCP traffic over the socket.McpServerhandles the MCP lifecycle (initialize, roots,ping) per connection and exposes no application-level tools; anon_roots_changedcallback triggers root re-sync when an MCP client updates its root list.
Sessions
In the daemon model a session is a connected agent, not a process. A
session is identified by its bridge MCP connection and the session_id
its hooks carry, and appears on the state.json session board with its
client, roots, and status. All sessions share the daemon’s LSP server
pool and tool servers — there is no per-session language server. What is
per-session is lightweight: the editing state (EditingManager,
the accumulated set of edited files), created on the session’s first
hook dispatch. A session ends when its connection disconnects, dropping
it from the board; the daemon keeps running for the other sessions, and
exits only when the last one disconnects.
Root discovery
Workspace roots are known at startup from CATENARY_ROOTS or the
current directory. The MCP initialize handshake may also provide
roots via roots/list. Each root is checked for a .catenary.toml
project config, which can override language and server definitions for
that root’s scope.
Per-root classification tables are derived from both the user config
and any project config. These tables map file extensions, filenames,
and shebangs to language IDs, and are used by FilesystemManager for
language detection.
Serving
Once initialized, the daemon serves requests from two sockets: CLI commands and hook events over the IPC socket (grep, glob, diagnostics, roots, editing enforcement) and MCP traffic over the MCP socket (the bridge proxy forwards the handshake and root updates). Each CLI command follows this sequence:
-
File change notification. The walk a command already performs (grep’s search walk, glob’s directory scan, the diagnostics stat-walk) doubles as change detection: observed
(path, mtime)pairs are diffed against each root’s baseline (diff_and_update), and only the files that actually changed are sent — as a precise, per-server changed-set (nudge_changed_set) — viaworkspace/didChangeWatchedFilesto servers whose registered watchers match. -
IPC dispatch. The IPC router dispatches the request to the appropriate application server:
grep→GrepServer— parallel ripgrep + LSP symbol index search, LSP enrichment.glob→GlobServer— file listing with structural symbol outlines from LSPdocumentSymbol.diagnostics→ ends the editing batch, runs batched diagnostics across all modified files (editing starts implicitly on the first covered edit — there is no separate start command).
-
LSP interaction. Application servers use
LspClientManagerto find the right server(s) for each file, wait for readiness, open documents, send LSP requests, and collect responses. Multi-server languages use priority-chain dispatch for request/response methods (first non-empty result wins) and diagnostic concatenation (all enabled servers contribute). -
Result return. The application server returns a result string printed to the CLI command’s stdout.
Editing mode
Editing mode brackets a batch of file edits. It starts implicitly on the
first edit to a server-covered file — there is no separate start command
— and ends when the agent runs catenary diagnostics. The host CLI’s
Edit/Write tools modify files directly — as do native shell writes like
sed -i, whose write-set the PreToolUse hook resolves; the hook tracks
which files are modified. When catenary diagnostics runs, DiagnosticsServer opens all modified files on their
respective language servers, waits for each server to settle, retrieves
diagnostics, and prints a consolidated report to stdout.
While covered edits are pending, the PreToolUse hook enforces
boundaries: only edit-related tools (Edit, Write, filesystem Bash
commands, and canonical Catenary commands) are allowed without running
catenary diagnostics first.
Mid-session root addition
When a workspace root is added (catenary pin <path> via the
host’s shell tool, or via MCP roots/list update), Catenary processes
it through Session::sync_roots:
FilesystemManagerroots are updated; a newly added root has no baseline yet, so its first walk becomes the baseline (no re-seed).- Project configs are loaded for new roots; classification tables are updated.
- Workspace-capable servers receive
didChangeWorkspaceFoldersnotifications (additions for non-project-scoped roots, removals for roots that disappeared). - Per-root settings from project configs are sent via
didChangeConfiguration. - Legacy servers get new
Scope::Rootinstances spawned for added roots and existing instances shut down for removed roots. spawn_allruns again to detect languages in new roots.
Shutdown
The daemon exits when the last client disconnects, on catenary stop,
or on SIGINT/SIGTERM:
- The accept loop stops and both socket listeners are torn down.
Session::shutdown()sends LSPshutdownrequests to all active servers, waits for responses, then sendsexitnotifications.- The JSONL firehose is flushed — the queue drains and the writer thread joins.
- Dropping the
SessionManagerremoves both daemon sockets (catenary.sockandcatenary-mcp.sock), so a stale bridge cannot reconnect to a dead daemon.
(An individual session ending is not a daemon shutdown — it just
disconnects and drops off the state.json board while the daemon keeps
serving the rest.)
Confirming a stop
Run in an interactive terminal with sessions still connected, catenary stop prints the session board first — each connected session’s host,
workspace root(s), and how long it has been connected, read from the
state.json snapshot — and asks for confirmation before disconnecting
anyone. Declining (the default) exits 0 with the daemon left running.
--force skips the prompt (scripts, and the documented upgrade flow), and a
non-interactive stdin skips it too. After the stop, a warning still names how
many sessions lost tooling — each needs a /mcp reconnect, since a host
restart alone won’t respawn the daemon.
TUI monitoring
Running catenary with no subcommand in an interactive terminal launches
the read-only TUI dashboard. It is a pure file reader: it file-watches
the daemon-owned state.json snapshot under runtime_dir and reloads on
change — it never connects to the daemon, the firehose, or a database, so
it cannot affect (or wedge) a running session.
The snapshot holds live state only, so the dashboard renders a health/config surface rather than a message stream: a 2×2 master-detail grid with the Servers (by root) tree and Sessions (by client) tree on the left, a contextual Details pane top-right, and the problems pane bottom-right. On narrow terminals the four panes stack full-width.
For full protocol and trace history — request/response pairing, the LSP
traffic behind a single command — query the firehose with catenary query (e.g. catenary query --session <id>).