Catenary
Catenary gives AI coding agents LSP-powered code intelligence. It manages a pool of language servers and exposes them through CLI commands and hooks — search, diagnostics, and navigation without shell-based text scanning.
Two CLI search commands — catenary grep and catenary glob — plus a
tracked editing surface: edits flow through the host’s Edit/Write tools
(or native sed -i for sweeps, whose writes the hook tracks too), and
catenary diagnostics reports the errors and warnings for every file
touched. Editing is tracked
automatically — the first edit starts it, there is no start step. The
agent never needs to know which language server handles which file.
- Migrating to 2.0 — what changed in the 2.0 release
- Installation — install the binary and connect it to your CLI
- Configuration — configure language servers and settings
- Notifications — how Catenary surfaces warnings and errors
- CLI & Dashboard — the command surface and TUI dashboard
- Language Servers — per-language setup guides
- Architecture — how the daemon, surfaces, and routing fit together
- Tracing Conventions — severity guidelines and structured fields
Migrating to 2.0
Catenary 2.0 is a major release. Every breaking change is in user
configuration — your ~/.config/catenary/config.toml and any project
.catenary.toml. Agent-facing workflow changes (implicit editing start, the
catenary diagnostics command) need no migration: the host’s session primer
teaches them fresh each session, so they are not an upgrade concern.
If you have never written a [commands] section, only the last item — the new
optional knobs — applies to you, and even those have safe defaults.
At a glance
| Change | Affects you if… | Action |
|---|---|---|
Writes resolve-or-deny; allow_file_redirects retired | your config set allow_file_redirects | delete the key — the write model is now automatic |
awk/sed dropped from the default pipeline | your [commands].pipeline lists awk or sed | remove them; sweep with native sed -i / perl -i -pe |
Project [commands] enforcement keys ignored | you set enforcement keys in a .catenary.toml | move them to user config |
| New diagnostics + notification knobs | — (optional) | nothing required; tune if desired |
1. Writes resolve-or-deny
The allow_file_redirects knob is retired. There is no on/off switch for
redirects any more, and setting the key has no effect (a stale config still
loads — the key is silently ignored).
In its place, every shell write is judged by one config-free rule:
resolve-or-deny. Before a command runs, the PreToolUse hook resolves the
complete set of files it will write — from shell grammar (>, >>, &>,
heredoc targets), argument convention (cp, mv, tee, sed -i, ln),
checkable interpreter programs (awk, perl -pe), or a state query
(hook-expanded globs, git asked about its own index). A write whose target set
resolves is allowed and recorded into your modified-set, so the next
catenary diagnostics sees it; catenary grep pat > hits.txt is a first-class,
tracked redirect. A write whose targets cannot be seen — > $DYNAMIC,
python -c "open(…,'w')", xargs sed -i — is denied with a message that
teaches the resolvable form. File-descriptor duplications (2>&1, >&2) and
device sinks (/dev/null, /dev/stdout, /dev/stderr) are never writes.
If your config set allow_file_redirects, delete the line. Resolvable
redirects that used to need the opt-in now just work; the rare opaque one is
denied with guidance.
See Command Filtering → The write model.
2. awk and sed removed from the default pipeline
The recommended [commands].pipeline no longer includes awk or sed. The
shipped default is now:
pipeline = ["grep", "wc", "jq", "sort", "tr", "cut", "uniq"]
Both awk and sed can execute arbitrary code and write files in-band
(sed -i/sed w, awk’s system() and print > file). The filter
quote-masks an interpreter’s program string before parsing, so it cannot see
those side effects — which would silently bypass the tracked Edit/Write path.
For that reason they are denied at every pipeline position now.
If your pipeline list includes awk or sed, remove them. Your existing
file is honored as written, so they keep working until you regenerate or edit
your config — but they are an exec/write hole and should be dropped.
For sweeping multi-file edits, reach for native sed -i (or perl -i -pe
when you need look-around or back-references): the PreToolUse hook resolves
the write-set from the command line and records the touched files into the
diagnostics batch, so diagnostics stay complete. See Command Filtering → The
write model.
Regenerate the recommended template any time with catenary config.
See Command Filtering → Recommended [commands] config.
3. Project [commands] enforcement keys are ignored
In a project .catenary.toml, the [commands] table now honors only
build (the per-root build tool). Every enforcement key is user-level only
and is ignored at project scope:
client_enforcement_onlyallowpipelinedenydeny_flagsallow_flagsscript_hostsguidance
Catenary warns when it sees one of these in a project file.
Why: the command filter resolves daemon-globally — one Catenary daemon serves every connected session. A project that changed enforcement would change the filter every session sees, including agents in unrelated repositories. The tighten/turn-on direction would fail silently (enforcement simply never engages), so the keys are refused at project scope outright.
If you set any of these in a .catenary.toml, move them to your user config
at ~/.config/catenary/config.toml. build stays where it is:
# .catenary.toml — still valid
[commands]
build = "make"
# ~/.config/catenary/config.toml — enforcement lives here now
[commands]
allow = ["git", "gh", "cp", "rm", "mkdir", "mv", "touch", "cat", "head", "diff"]
pipeline = ["grep", "wc", "jq", "sort", "tr", "cut", "uniq"]
[commands.deny]
git = ["grep", "ls-files", "ls-tree"]
See Command Filtering → Project-scoped commands.
4. New optional knobs
These are additive — they have defaults and require no action — but they are new surfaces you may want to set.
catenary diagnostics tuning
[tools]
diagnostics_severity = "error" # default
diagnostics_severity(default"error") — the minimum severity that labels a run “dirty” (vs “clean”). One of"error","warning","info","hint". A status label only: the run always exits0and prints every diagnostic; it no longer gates an exit code.
See Configuration → Diagnostics.
Notifications: threshold removed
The store-and-forward systemMessage notification queue retired, and with it
its severity floor. The [notifications] table now has one knob:
[notifications]
desktop = true # default — OS notifications for error-severity events
threshold— removed. Warns now persist on the TUI health dashboard (a warn is a health finding) and everything is queryable viacatenary query; neither is severity-tunable. A leftoverthresholddoes not break startup — it is ignored — butcatenary doctorflags it as an unknown key, so delete it.desktop(defaulttrue) — fire OS-level desktop notifications for error-severity events (the urgent interrupt).CATENARY_NOTIFY=0also suppresses.
See Configuration → Notifications and Notifications.
Not a migration concern
These changed in 2.0 but need no config action — the session primer teaches the current workflow each session:
- Editing starts implicitly on the first edit; there is no
editing startstep (it remains an idempotent no-op). editing stopis nowcatenary diagnostics— it ends the edit batch and prints diagnostics for every modified file.
Installation
Prerequisites
- Rust toolchain
- Language servers for the languages you want to use (see Language Servers)
Platforms
Catenary ships prebuilt binaries for Linux x86_64 and macOS arm64 (Apple silicon). There is no Intel-mac binary — the installer refuses Intel Macs and points you at a source build — and no Windows binary: the daemon’s transport is Unix-socket-bound today, so Windows support returns after the port rather than shipping unverified.
Install Catenary
Homebrew (macOS and Linux):
brew install twowells/tap/catenary
Switching from a
cargo install(the previously recommended path)? Runcargo uninstall catenary-mcpfirst —~/.cargo/binusually precedes brew’s bin dir onPATH, so the stale binary keeps answering otherwise.
Prebuilt binary (Linux / macOS arm64):
curl -fsSL https://raw.githubusercontent.com/TwoWells/Catenary/main/install.sh | sh
The script detects your platform, downloads the matching release asset
(catenary-linux-amd64 or catenary-macos-arm64), and installs it to
/usr/local/bin (override with CATENARY_INSTALL_DIR). Once installed,
catenary update self-updates the binary in place.
From crates.io (any platform with a Rust toolchain):
cargo install catenary-mcp
From source:
cargo install --git https://github.com/TwoWells/Catenary catenary-mcp
Connect to Your AI CLI
The
catenarybinary must be on your PATH before configuring any client. Plugins and extensions provide hooks and MCP server declarations but do not include the binary.
Claude Code (recommended: plugin)
claude plugin marketplace add TwoWells/Catenary
claude plugin install catenary@catenary
The plugin registers hooks for editing enforcement, command filtering,
and agent lifecycle tracking, plus an MCP connection for session
management and workspace root discovery. It also owns worktree creation
(the WorktreeCreate hook), placing each isolation:"worktree" subagent
worktree outside your repo under the cache dir so language servers never
index it as a duplicate copy of your project.
OpenCode (plugin)
catenary install opencode
OpenCode has no hooks.json surface, so Catenary ships an in-process plugin.
The install is plugin-only: it writes exactly one Catenary-owned file —
~/.config/opencode/plugin/catenary.js (the plugin) — and makes zero edits to
your opencode.json. On config load the plugin injects the MCP heartbeat
(mcp.catenary) and regenerates its teaching from the binary by itself, so
nothing is merged into your config. Teaching is runtime-only — there is no
shipped static fallback file. Pass --workspace to install into the project
(.opencode/) instead of globally.
Because the whole integration rides one plugin, there is a single disable
switch and it turns off everything together — enforcement, teaching, and the MCP
heartbeat: delete or rename that one file, plugin/catenary.js, or launch
OpenCode with OPENCODE_PURE=1 (which disables all external plugins). See
Disabling Catenary per project below.
Upgrading from an earlier version? Older releases merged an
mcp.catenaryentry and aninstructionsreference to a rules file into youropencode.json, and shipped a static~/.config/opencode/catenary.mdteaching fallback. The plugin now carries the heartbeat and regenerates its teaching from the binary at runtime, so those are all redundant: you may remove the mergedmcp.catenary/instructionsentries, delete~/.config/opencode/catenary.md, and drop any oldinstructionsentry naming it. Leaving the mergedmcp.catenaryentry is harmless — the plugin defers to it.
Manual MCP registration
For other clients, or if you prefer manual setup:
{
"mcpServers": {
"catenary": {
"command": "catenary"
}
}
}
This registers the MCP connection only. Without the plugin/extension, you will not get editing state enforcement or command filtering.
Disabling Catenary per project
Catenary runs one daemon per host, shared across every project you open. If you want its enforcement off in a single project — while it keeps serving every other project — most hosts let you switch off the plugin, extension, or hook set for that project alone. This is an option, not a recommendation.
What disabling turns off, in that project only: the hooks (editing enforcement,
command filtering, file tracking) and, where the host’s plugin also carries it,
the MCP session wiring. What stays: there is nothing to uninstall, the daemon
keeps running and serving your other projects, and the catenary binary and its
CLI commands (grep, glob, diagnostics) still work if you invoke them by
hand. Re-enabling resumes cleanly — editing state is per-session, so nothing
stale persists; the next enabled run starts tracking from a clean slate.
Claude Code
Set the plugin to false in the project’s .claude/settings.json:
{
"enabledPlugins": {
"catenary@catenary": false
}
}
Committed to the repository, this disables the Catenary plugin for everyone who
opens the project — project settings override user settings. For a personal,
uncommitted opt-out, put the same block in .claude/settings.local.json
(git-ignored, and higher precedence still). The precedence order is Local >
Project > User, so either file overrides a plugin enabled in your
~/.claude/settings.json. claude plugin disable catenary@catenary --scope project writes the same entry for you. Disabling the plugin stops both its
hooks and its MCP connection for that project.
See the Claude Code plugin docs and settings precedence.
OpenCode
Catenary integrates with OpenCode through a single in-process plugin, so there
is one switch and it turns off the whole integration at once — enforcement,
teaching, and the MCP heartbeat all ride this one plugin. OpenCode has no
per-plugin disable key in opencode.json (it is an open feature request);
plugins are auto-loaded from a plugin directory instead, so the per-project
story depends on how Catenary was installed:
- Installed per-workspace (
catenary install opencode --workspace): the plugin file lives in the project at.opencode/plugin/catenary.js. Delete or rename that file to disable Catenary in this project only; other projects are untouched. - Installed globally (
~/.config/opencode/plugin/catenary.js): there is no per-project switch. Removing or renaming the global file disables Catenary in every OpenCode project. To turn it off for a single session without deleting anything, launch OpenCode withOPENCODE_PURE=1, which disables all external plugins (not just Catenary).
There is no partial toggle — no “keep teaching, drop the heartbeat” — because the plugin is the only integration surface. See the OpenCode plugin docs.
Antigravity
Antigravity has no per-workspace disable toggle either. It discovers plugins by
location: global plugins live under ~/.gemini/config/plugins/ (where catenary install antigravity places catenary/), and workspace plugins live under
.agents/plugins/ (or _agents/plugins/) at the project root. A
globally-installed Catenary plugin therefore has no per-project off switch;
removing ~/.gemini/config/plugins/catenary disables it in every project. See
the Antigravity plugin docs.
Verify
catenary doctor
For each configured server, doctor reports:
| Status | Meaning |
|---|---|
ready | Server spawned, initialized, and capabilities listed |
command not found | Binary not on $PATH |
spawn failed | Binary found but process failed to start |
initialize failed | Process started but LSP handshake failed |
Use --root to check a different workspace:
catenary doctor --root /path/to/project
For detailed diagnostics on a single server (resolved command, stderr capture, full init request/response, capabilities):
catenary doctor rust-analyzer
Next Steps
- Configure your language servers
- Install language servers for your languages
Configuration
Catenary loads configuration from multiple sources, in order of priority (last wins):
- Built-in defaults: Server definitions (
defaults/servers.toml) and language classification with server bindings (defaults/languages.toml). Common language servers work without any config — if the binary is on PATH, Catenary uses it. - User config:
~/.config/catenary/config.toml. - Project config:
.catenary.tomlin each workspace root. Discovered when roots are added (at startup or viacatenary pin). Scoped to[lsp](thedisabletoggle plus[lsp.server.*]/[lsp.language.*]definitions),[linter](disableplus[linter.rule.*]),[diagnostics](disable), and[commands]buildonly — every other[commands]key and all other sections are user-level (see Project-scoped commands). - Explicit file:
--config <path>. - Environment variables: Prefixed with
CATENARY_(e.g.,CATENARY_LOG_RETENTION_DAYS=30). Use__for nested keys (e.g.,CATENARY_ICONS__PRESET=nerd).
Language Servers
Configuration uses two sections: [lsp.server.*] defines how to run a
language server, and [lsp.language.*] binds languages to servers.
The section key <name> is the server binary Catenary spawns; add an
optional path = "/abs/path" only to relocate a binary that is not on PATH.
[lsp.server.<name>]
args = ["arg1", "arg2"]
[lsp.language.<language-id>]
servers = ["<name>"]
Built-in Defaults
Catenary ships built-in definitions for ~25 common language servers. If
the server binary is on PATH and the language has a default binding, LSP
intelligence works without any [lsp.server.*] or [lsp.language.*] config.
Run catenary config to see the full list of built-in servers.
A user-defined [lsp.server.X] completely replaces the built-in default for
X — no merging. If you define [lsp.server.rust-analyzer], your definition
is used and the built-in is ignored entirely.
Example
The built-in defaults cover the basics. You only need config for
customisation — initialization_options, settings, env, etc.:
# Override the built-in rust-analyzer with custom options
[lsp.server.rust-analyzer]
env = { CLIPPY_DISABLE_DOCS_LINKS = "1" }
[lsp.server.rust-analyzer.initialization_options]
check.command = "clippy"
cargo.features = "all"
diagnostics.disabled = ["inactive-code"]
# Override pyright with workspace settings
[lsp.server.pyright-langserver.settings.python]
pythonPath = "/usr/bin/python3"
[lsp.server.pyright-langserver.settings.python.analysis]
exclude = ["**/target", "**/node_modules"]
extraPaths = []
To define a server from scratch (or one without a built-in default):
[lsp.server.phpactor]
args = ["language-server"]
[lsp.language.php]
servers = ["phpactor"]
Initialization Options
Server-specific options passed during the LSP initialize request.
These go on the [lsp.server.*] entry:
[lsp.server.rust-analyzer.initialization_options]
check.command = "clippy"
cargo.features = "all"
Refer to your language server’s documentation for available options.
Server Settings
Some language servers request configuration from the client via
workspace/configuration. The settings table provides these values
on the [lsp.server.*] entry. The TOML nesting mirrors the JSON object
the server expects — Catenary matches the section path from each
request and returns the corresponding subtree.
[lsp.server.pyright-langserver.settings.python]
pythonPath = "/usr/bin/python3"
[lsp.server.pyright-langserver.settings.python.analysis]
exclude = ["**/target", "**/node_modules"]
extraPaths = []
When pyright sends workspace/configuration with
{ "items": [{ "section": "python.analysis" }] }, Catenary returns
the matching subtree from [lsp.server.pyright-langserver.settings].
Items with no matching path receive {}.
Diagnostic Severity
min_severity on [lsp.server.*] filters which diagnostics are delivered to
agents. Valid values: "error", "warning", "information", "hint".
When absent, all severities are delivered.
[lsp.server.lattice]
args = ["serve"]
min_severity = "warning"
Environment Variables
env on [lsp.server.*] sets environment variables on the spawned server
process. Variables are added to the inherited environment — if a key
already exists, the config value wins.
[lsp.server.rust-analyzer]
env = { CLIPPY_DISABLE_DOCS_LINKS = "1" }
Use cases include stripping lint URLs from diagnostics (saves agent context tokens), setting custom module paths, and passing runtime flags to language servers that read them from the environment.
Multi-server Bindings
The servers list on [lsp.language.*] supports multiple servers. List order
defines dispatch priority — for request/response methods, Catenary tries
each server in order and returns the first non-empty result.
[lsp.language.shellscript]
servers = ["termux-language-server", "bash-language-server"]
To suppress diagnostics from a specific server, use inline-table syntax:
[lsp.language.shellscript]
servers = [
"termux-language-server",
{ name = "bash-language-server", diagnostics = false },
]
Bare strings expand to { name = "...", diagnostics = true }.
To suppress all diagnostics for a language, set diagnostics = false on the
language entry:
[lsp.language.markdown]
servers = ["lattice"]
diagnostics = false
Precedence: language.diagnostics AND binding.diagnostics. Either false
suppresses delivery.
[lsp.language.*].diagnostics | Per-binding diagnostics | Effective |
|---|---|---|
unset / true | unset / true | deliver |
false | any | suppress (language-wide) |
unset / true | false | suppress (per-server) |
To suppress specific LSP methods from a server for a language binding, use
disabled_methods:
[lsp.language.shellscript]
servers = [
"termux-language-server",
{ name = "bash-language-server", disabled_methods = ["textDocument/references"] },
]
When a method appears in disabled_methods, the server is excluded from
dispatch for that method. Other methods (definition, document symbols, etc.)
remain available. Method names use the LSP protocol form.
Dispatch Filtering
file_patterns on [lsp.server.*] narrows which files a server handles
within its language. Patterns match against the filename (not the full path).
Servers without file_patterns handle all files for their language.
[lsp.server.termux-language-server]
args = ["--stdio"]
file_patterns = ["PKGBUILD", "*.ebuild"]
[lsp.server.bash-language-server]
args = ["start"]
[lsp.language.shellscript]
servers = ["termux-language-server", "bash-language-server"]
Here, termux-language-server only receives PKGBUILD and *.ebuild files.
bash-language-server has no file_patterns, so it handles all shellscript
files. For a PKGBUILD file, both servers are active — termux-language-server
is tried first (higher priority), with bash-language-server as fallback.
Single-file Mode
single_file = true on [lsp.server.*] enables tier 3 routing: files
outside all workspace roots get a dedicated server instance with
rootUri: null and workspaceFolders: null (per the LSP spec’s
single-file semantics). The server operates on individual documents
without workspace context.
[lsp.server.bash-language-server]
args = ["start"]
single_file = true
Servers configured with single_file = true also track out-of-root
edits through the implicit editing batch, so catenary diagnostics
reports errors for files outside the workspace. If the server rejects
null-workspace
initialization at runtime, the failure is cached and the server is not
retried for the remainder of the session.
Default is false. Servers that require a project root (Cargo.toml,
tsconfig.json, etc.) should leave this unset.
Why config-driven, not auto-detected? The LSP spec allows rootUri
to be null, and most servers accept it — but “accepts initialization”
doesn’t mean “works well.” rust-analyzer initialises with null
workspace and enters detached-file mode, but provides heavily degraded
results. bash-language-server works fine. There is no LSP capability
flag that distinguishes these cases. Neovim’s nvim-lspconfig uses the
same approach: a per-server single_file_support flag, opt-in, set by
the server config maintainers who know which servers handle it well.
Root Markers
root_markers on [lsp.language.*] defines project boundary files for
sub-root resolution. When a file in a workspace root needs a server
instance that doesn’t exist yet, Catenary walks up from the file
toward the workspace root boundary, stopping at the first directory
containing any marker. That directory becomes the server instance’s
root.
[lsp.language.rust]
root_markers = ["Cargo.toml"]
Entries can be exact filenames or glob patterns (*, ?, [). Exact
filenames use a fast exists() check; glob patterns are compiled at
config load time and matched against directory entries. This is useful
for ecosystems where project files have varying names:
[lsp.language.csharp]
root_markers = ["*.sln", "*.csproj"]
This fixes polyglot repos and monorepos where the workspace root is
broader than what a server needs. For example, a chezmoi dotfiles repo
with Neovim config at dot_config/nvim/ — lua_ls rooted at the
chezmoi root never finds dot_config/nvim/.luarc.json. With
root_markers = [".luarc.json"], lua_ls spawns rooted at the
subdirectory and discovers the config.
Defaults are shipped for common languages (Rust, Go, Python,
TypeScript, Lua, Java, C/C++, C#, F#, and others) in the builtin
config. Run catenary doctor <server> to see active markers. Override
per-language:
# Custom markers
[lsp.language.rust]
root_markers = ["rust-toolchain.toml"]
# Disable markers entirely
[lsp.language.python]
root_markers = []
Key behaviors:
- Bounded by workspace root. The walk never escapes above the workspace root. Markers subdivide within roots — they don’t extend beyond them.
- Nearest wins. The closest marker to the file is used. Nested
markers (workspace
Cargo.toml+ crateCargo.toml) resolve to the nearest. - No marker → workspace root. Falls back to current behavior when no marker exists.
- Eager/lazy spawn. If the workspace root itself contains a marker, the server spawns at startup. If markers only exist in subdirectories, spawn is deferred until a file there is first accessed.
- Instance isolation. Files in different marker-resolved sub-roots get separate server instances. Files in the same sub-root share one.
Custom Languages
Define a custom language by adding a [lsp.language.*] entry with
classification fields and a server binding:
[lsp.language.pkgbuild]
filenames = ["PKGBUILD"]
servers = ["termux-language-server"]
Classification fields:
extensions— file extensions without the dot (e.g.,["sh", "bash"])filenames— exact filename matches (e.g.,["PKGBUILD", "Makefile"])shebangs— interpreter basenames for#!detection (e.g.,["bash", "sh"])
Setting a field replaces the default value (if any). Fields not specified inherit from the default classification. Setting a field to an empty list clears the default.
Classification precedence (highest first): shebang > filename > extension.
Project Configuration
Place a .catenary.toml in a workspace root to override language and
server configuration, set the per-project build tool, and toggle the
diagnostic feeders for that root. Each subsystem is one self-contained
table: [lsp] (its disable toggle plus [lsp.server.*] / [lsp.language.*]
definitions), [linter] (its disable toggle plus [linter.rule.*] linter
definitions), [diagnostics] (its disable toggle), and [commands] (the
build tool only — command enforcement is user-level; see
Project-scoped commands). Other sections
([notifications], [icons], etc.) are user-level and belong in
~/.config/catenary/config.toml.
Project config is discovered when roots are added (at startup or via
catenary pin). Changes to .catenary.toml require restarting
the session.
Disabling feeders per root
Three orthogonal, per-root toggles control each diagnostic feeder
independently — the disable key nested under each subsystem’s table.
All default to false and are scoped to the root whose .catenary.toml
declares them — a multi-project daemon honours each root’s choice
separately.
# .catenary.toml
[lsp]
disable = true # no language servers for this root
[diagnostics]
disable = true # diagnostics surface off, navigation kept
[lsp] disable— drops the LSP feeder: no language servers spawn for this root, so there is no grep/glob enrichment and no LSP diagnostics. The root stays tracked everywhere else (catenary roots, the build tool, command resolution, the editing gate). Useful for media collections, data directories, or any root where a language server is pure overhead. (Polarity flip of the oldlsp = false.)[linter] disable— drops the standalone-linter feeder: no linter diagnostics for this root.[diagnostics] disable— suppresses the diagnostics surface (the editing→catenary diagnosticsgate and its output) while keeping LSP servers running for grep/glob navigation. Use it when you want code intelligence but no edit-time diagnostics friction.
[lsp] disable together with [linter] disable also zeroes diagnostics, but
kills navigation too; [diagnostics] disable keeps navigation — that is the
distinction.
Migration: The
lspkey (and its oldenabledalias) was removed in 2.0. Replacelsp = falsewith a[lsp]table carryingdisable = true— the polarity flips. A leftoverlsp/enabledkey is now a hard error, flagged bycatenary doctor.
Merge Semantics
Project config is deep-merged with user config at the key level:
- Scalars replace —
path,args,min_severity. - Tables deep-merge by key — a project
[lsp.server.rust-analyzer]with justsettingsinheritspathandargsfrom the user’s (or built-in)[lsp.server.rust-analyzer]. - Arrays replace —
servers,file_patterns,extensions,filenames,shebangs.
Project override example
Override rust-analyzer settings for a specific project:
# .catenary.toml (in project root)
[lsp.server.rust-analyzer.settings.rust-analyzer]
check.targets = ["aarch64-unknown-linux-gnu"]
cargo.features = ["embedded"]
This merges with the built-in (or user-defined) [lsp.server.rust-analyzer]
definition — the project inherits path, args, and
initialization_options, and overrides only the settings subtree.
Tier Promotion
Adding a [lsp.language.*] entry in project config promotes that language
to a project-scoped server instance — a separate process bound to this
root. Without a [lsp.language.*] entry, the shared server instance serves
this root with scopeUri-merged settings.
# .catenary.toml — promotes rust to a project-scoped instance
[lsp.language.rust]
servers = ["rust-analyzer"]
[lsp.server.rust-analyzer.settings.rust-analyzer]
cargo.features = ["embedded"]
Language IDs
The [lsp.language.<language-id>] key in the language section must match the LSP language identifier.
Catenary auto-detects languages from file extensions, filenames, and
shebangs (#! lines in extensionless scripts). Any language with an LSP
server works — this table covers what Catenary recognises automatically.
To extend or override these defaults, see Custom Languages.
By extension
| Extension | Language ID |
|---|---|
.rs | rust |
.go | go |
.c | c |
.cpp, .cc, .cxx, .h, .hpp | cpp |
.zig | zig |
.d | d |
.v | v |
.nim | nim |
.java | java |
.kt, .kts | kotlin |
.scala, .sc | scala |
.groovy, .gvy | groovy |
.clj, .cljs, .cljc | clojure |
.cs | csharp |
.fs, .fsx, .fsi | fsharp |
.swift | swift |
.m, .mm | objective-c |
.py | python |
.rb | ruby |
.pl, .pm | perl |
.php | php |
.lua | lua |
.tcl | tcl |
.cr | crystal |
.js, .mjs, .cjs | javascript |
.ts, .mts, .cts | typescript |
.tsx | typescriptreact |
.jsx | javascriptreact |
.hs, .lhs | haskell |
.ml, .mli | ocaml |
.elm | elm |
.gleam | gleam |
.ex, .exs | elixir |
.erl, .hrl | erlang |
.purs | purescript |
.sh, .bash, .zsh, .ebuild, .eclass, .install | shellscript |
.fish | fish |
.ps1, .psm1, .psd1 | powershell |
.r, .R | r |
.jl | julia |
.mojo | mojo |
.html, .htm | html |
.css | css |
.scss | scss |
.sass | sass |
.less | less |
.svelte | svelte |
.vue | vue |
.json, .jsonc | json |
.yaml, .yml | yaml |
.toml | toml |
.xml, .xsl, .xslt, .xsd | xml |
.sql | sql |
.graphql, .gql | graphql |
.proto | proto |
.md, .mdx | markdown |
.rst | restructuredtext |
.tex, .latex | latex |
.typ | typst |
.nix | nix |
.tf, .tfvars | terraform |
.cmake | cmake |
.dart | dart |
.dockerfile | dockerfile |
By filename
| Filename | Language ID |
|---|---|
Dockerfile | dockerfile |
Makefile, GNUmakefile | makefile |
CMakeLists.txt | cmake |
Cargo.toml, Cargo.lock | toml |
Gemfile, Rakefile | ruby |
Justfile, justfile | just |
PKGBUILD | shellscript |
By shebang
For files without a recognised extension, Catenary reads the first line.
If it starts with #!, the interpreter name is matched:
| Interpreter | Language ID |
|---|---|
bash, sh, zsh, dash, ksh | shellscript |
fish | fish |
python, python3, python2 | python |
node, nodejs | javascript |
deno | typescript |
ruby, irb | ruby |
perl | perl |
php | php |
lua, luajit | lua |
tclsh, wish | tcl |
Rscript | r |
julia | julia |
elixir, iex | elixir |
erl | erlang |
swift | swift |
kotlin | kotlin |
scala | scala |
groovy | groovy |
crystal | crystal |
Command Filtering
The [commands] section defines which shell commands an agent may run.
Allowlist-based: only explicitly permitted commands can execute.
Everything else is denied. The denial names the blocked command, shows the
cwd’s build tool when the command is build-related, and points the agent at
catenary commands — which prints the active
allow / pipeline / denied surface — so the full configuration lives in one
place instead of being dumped inline on every denial.
Three states
- No
[commands]section — not configured yet. Catenary emits a hint notification once per session at startup. client_enforcement_only = true— deliberate opt-out. No hint, no enforcement.allow = [...]present — active allowlist. Enforce.
Recommended [commands] config
[commands]
build = "make"
# `allow` includes read/stdout-only tools (cat, head, less, diff, ...):
# reads aren't a write vector, and a redirected write (`cat > f`) is
# resolved and attributed by the write resolver, not blocked by denying cat.
# `sed` and `perl` are allowed as bulk writers: their in-place edits
# (`sed -i`, `perl -i -pe`) are script-checked and resolved into the
# diagnostics batch; an unparseable/executing script is surgically denied.
# perl is a nicer sed here — inline `-e`/`-E` programs only; a script file
# (`perl script.pl`) or a program read from stdin (bare `perl`) runs code the
# hook can't see and is denied.
allow = ["git", "gh", "cp", "rm", "mkdir", "mv", "touch",
"chmod", "sleep", "cd", "true", "false", "which",
"cat", "head", "tail", "less", "more", "diff",
"echo", "printf", "seq", "sed", "perl"]
pipeline = ["grep", "wc", "jq", "sort", "tr", "cut", "uniq"]
[commands.deny]
git = ["grep", "ls-files", "ls-tree"]
sqlite3 = ["-cmd"]
Read and stdout-only tools (cat, head, tail, less, diff, …)
live in allow, not pipeline: reads are not a write vector, and a
redirected write like cat > f is handled by the write model
— resolved to its target and recorded, or denied when opaque — so there is no
need to block the reader. awk and sed are deliberately absent from the
default pipeline, but not because they are banned: their programs are
checked by the resolver (a pure awk filter or sed script passes; an
in-program system()/print > file, or sed -i, resolves to its write-set
or is surgically denied), so keeping them out of the position-0 pipeline simply
avoids masking that check behind a bare awk 'prog'.
Keys
| Key | Description |
|---|---|
client_enforcement_only | Deliberate opt-out — no enforcement, no hint notification. |
build | The project’s build tool (e.g., "make"). On denial of a build-related command, the response directs the agent to the configured build tool. |
allow | Commands the agent can run unconditionally (including read/stdout-only tools — reads are not a write vector). |
pipeline | Commands allowed mid-pipeline (reading stdin) but denied at pipeline position 0 (reading files directly). Prevents grep foo bar.rs while allowing make test | grep FAIL. |
deny.<cmd> | Subcommand denylist within an allowed command. git is allowed, but git grep is denied. |
deny_flags.<cmd> | Flag denylist within an allowed command. make is allowed, but make -C is denied. |
allow_flags.<cmd> | Allowed invocation forms for a permitted command (the allow-side dual of deny_flags). When present, an invocation must match one listed form or it is denied naming them. See Allowed forms. |
script_hosts | Commands opted in as script hosts — a modeled substitution engine (perl/awk/sed) whose script-file form runs at the executor boundary instead of the default soundness denial. See Script hosts. |
guidance.<group> | Optional per-command hint shown on denial — a message, or a redirect naming the Catenary command to use instead (grep → catenary grep, glob → catenary glob). |
Allowed forms (allow_flags)
deny.<cmd> and deny_flags.<cmd> subtract from what a permitted command may
do. allow_flags.<cmd> is their allow-side dual: a per-command whitelist of
invocation forms. When a command has an allow_flags entry, an invocation
must match at least one listed form or it is denied with a message naming the
permitted forms.
[commands]
allow = ["perl"]
[commands.allow_flags]
# perl is a nicer sed here: only in-place edits and inline substitutions.
perl = ["-i", "-pe", "-e"]
With that config, perl -pe 's/a/b/' f and perl -i -pe 's/a/b/' f run;
perl -ne 'print' f is denied, naming -i, -pe, -e.
Each form is a positive anchor, cluster-normalized: -pe is the flag set
{p, e}, and an invocation matches when it carries all of the anchor’s
flags — so -i -pe and -w -pe both match the -pe anchor (extra flags do
not disqualify a match; they stay governed by the write model below). Long and
short forms are distinct tokens, matched as typed (--in-place ≠ -i).
allow_flags is policy, not soundness. It can only narrow: it never
re-opens a form the write model denies. A perl script.pl
runs a program the hook cannot audit, so it is denied whether or not a form is
listed — an unauditable shape has no flag to allow. deny/deny_flags also
still win: a denied flag is denied even inside a listed form. Like the other
enforcement keys, allow_flags is user-level only (ignored at project scope),
and its keys must name commands already in allow, pipeline, or build; an
empty form list is a config error (an allow set that permits nothing).
Script hosts (script_hosts)
allow_flags narrows within what the write model already
permits; script_hosts reaches the other direction. By default a modeled
substitution engine — perl, awk, sed — is a nicer sed, not a script
host: an inline program (perl -pe 's///', awk 'prog', sed 'script') is
checked and runs, but a script file the hook can’t read (perl script.pl,
awk -f prog.awk, sed -f script.sed) is denied — its in-program writes and
reads are invisible. That is the sound default.
script_hosts is the opt-in that relaxes it. A command listed here has its
script-file (and bare stdin-program) form re-classed to the executor
boundary — the same layer-4 stance python script.py keeps: NoWrite, with
the allowlist alone governing whether it runs.
[commands]
allow = ["perl"]
script_hosts = ["perl"]
With that config, perl script.pl args runs. Inline -e/-E code still faces
the substitution audit (a non-substitution perl -e 'print 1' stays denied —
inline code remains the denied vector, exactly as it is for python -c), and
perl -i still resolves its write-set into the diagnostics batch.
The three layers compose in order: default deny (a script the hook can’t
read) → script_hosts (re-class that form to the executor boundary) →
allow_flags (narrow which forms may run at all). Because a flagless
perl script.pl matches no allow_flags anchor, listing a command in both
script_hosts and allow_flags is contradictory — Catenary warns; drop the
allow_flags entry to use the command as a script host.
Like the other enforcement keys, script_hosts is user-level only (ignored at
project scope). Its keys must name a command in allow, pipeline, or build
(an unlisted command is a warned no-op), and listing an already-unbounded
interpreter (python/ruby/node, a script host by default) is a warned
no-op too; an empty list is a config error.
The write model
The allow/pipeline/deny lists above govern which programs may run.
How their writes are judged is a separate, config-free question:
resolve-or-deny. Before a command runs, the PreToolUse hook resolves the
complete set of files it will write — from shell grammar (>, >>, &>,
heredoc targets), argument convention (cp, mv, tee, sed -i, ln),
checkable interpreter programs (awk, perl -pe’s substitution subset), or a
state query (hook-expanded globs, git asked about its own index). A write whose
target set resolves is allowed and recorded into your modified-set, so the
next catenary diagnostics sees it. A write whose targets cannot be seen
(> $DYNAMIC, python -c "open(…,'w')", xargs sed -i) is denied with a
message that teaches the resolvable form. fd-dups (2>&1, >&2) and device
sinks (/dev/null, …) are never writes.
This is not a per-user knob — the design decides it (decision 026). There is no
allow_file_redirects setting: a resolvable > is a first-class, tracked
redirect; an opaque one is denied whatever the config says.
Inspecting the surface
catenary commands prints the active command surface for the current
configuration — the cwd’s build tool, then the allow / pipeline / denied
sections, and a closing line stating the resolve-or-deny write model — the same
[commands] rules the PreToolUse hook enforces. Run it
(via the host’s shell tool) to see what’s permitted; denial messages point
here rather than dumping the whole surface inline. The build tool is resolved
for the current directory the same way the denial hint is — the nearest
.catenary.toml’s per-root build, falling back to the user default — so an
agent that runs catenary commands eagerly learns its build tool up front.
It is a stateless read, so it runs even while the command filter is active.
Project-scoped commands
In .catenary.toml, [commands] honors only build — the per-root
build tool (“in this project, the build tool is make”). Even disabled
roots ([lsp] disable = true) contribute commands.build. In multi-root sessions
build is collected per-root; the evaluator resolves which root a command
targets via cwd.
# .catenary.toml
[commands]
build = "make"
Only build is honored. Every other [commands] key —
client_enforcement_only, allow, pipeline, deny, deny_flags,
allow_flags, script_hosts, and guidance — is user-level only and is
ignored at project scope (Catenary warns when it sees one). They must live
in ~/.config/catenary/config.toml.
This includes the on/off switch: a project cannot turn enforcement on
(client_enforcement_only = false) or off (client_enforcement_only = true) for itself.
Why: the command filter resolves daemon-globally. One Catenary daemon
serves every connected session, so a project that changed enforcement —
relaxing it (a wider allow) or trying to tighten it
(client_enforcement_only = false to request enforcement) — would change the
filter every session sees, including agents
in unrelated repos. Worse, the tighten/turn-on direction would fail
silently: enforcement never engages, so no agent ever hits a hook to
reveal that the project’s request was dropped. Keeping enforcement
user-level makes the filter exactly what the user configured, regardless of
which projects are open. build is exempt: it only names a build tool and
relaxes nothing.
Run catenary config to generate a recommended config template with
a commented-out [commands] section.
Global Options
| Option | Default | Description |
|---|---|---|
log_retention_days | 7 | Days to keep dead session data. 0 = remove on startup. -1 = retain forever. |
Companion Roots
The [roots.companions] table auto-mounts a derived sibling root alongside
each workspace root a host declares — so opening ~/Projects/Catenary (the code)
also mounts ~/Projects/CatenaryInternal (the planning repo) for LSP
intelligence, with no manual catenary pin each session.
Off by default. Catenary ships no table and assumes no naming convention; an
absent [roots.companions] disables the feature entirely.
[roots.companions]
"*" = "{root}Internal" # any root → its <path>Internal sibling
"~/Projects/homelab" = "~/.local/share/chezmoi" # explicit, unrelated path
Each entry maps a matcher (key) to a companion template (value):
| Side | Form | Meaning |
|---|---|---|
| Matcher | "*" | Matches any declared root. |
| Matcher | literal path | Matches that one root exactly (after ~/env expansion). |
| Template | {root} | The canonical root path — "{root}Internal" appends Internal. |
| Template | {name} | The root’s basename — "~/docs/{name}" mounts a cross-parent companion. |
| Template | literal path | A fully explicit companion path. |
~ and $VAR/${VAR} expand on both sides. Semantics are a union, not
first-match: every matching rule contributes a candidate, candidates are
existence-filtered (a companion is mounted only if it resolves to an existing
directory), de-duplicated, and never added if it equals a declared root. So
"*" = "{root}Internal" is safe to leave on globally — roots without an
Internal sibling simply contribute nothing.
Worktree-aware. A git worktree’s companion derives from its upstream
project, not its checkout path: a worktree at
~/Projects/Worktrees/Catenary-bug24 mounts ~/Projects/CatenaryInternal, not
…Catenary-bug24Internal. The upstream project is found by reading git’s own
.git/gitdir/commondir pointer files — no git binary or library is required.
The canonical project root is used only to derive the companion; it is never
itself mounted (you keep working in your worktree).
Lifecycle. Companions are scoped to the MCP connection that pulled them in. They are recomputed from the connection’s full declared-root set on every change, so adding a root adds its companion and removing a root drops its companion automatically. A companion shared by several connections (same project, multiple agents) stays mounted until the last connection closes, then drops with it.
User config only. [roots.companions] is read only from your user config
(~/.config/catenary/config.toml), never from a project .catenary.toml — a
public repository must not be able to leak a private sibling path. A [roots]
section placed in a project config is warned about and ignored.
Why it matters (Lattice synergy). With markdown defaulting to
Lattice, auto-mounting the *Internal planning repo lights up
its predicate/backlink intelligence: grep/glob enrichment across the planning
graph, and catenary diagnostics link/predicate checks on planning edits — for
free, every session.
Notifications
The [notifications] table has a single knob: desktop, which controls
whether error-severity events fire an OS-level desktop notification — the
urgent interrupt. Warns persist on the TUI health dashboard and everything is
queryable via catenary query, so there is no severity threshold to set (the
former threshold key retired with the notification queue). See
Notifications for the full channel model.
[notifications]
desktop = true # default
| Option | Default | Description |
|---|---|---|
desktop | true | Fire OS-level desktop notifications for error-severity events. Set false to suppress; CATENARY_NOTIFY=0 overrides to suppressed. |
Diagnostics
The [tools] table tunes catenary diagnostics (the command that ends an
edit batch and reports errors and warnings — see
CLI & Dashboard).
[tools]
diagnostics_severity = "error" # default
| Option | Default | Description |
|---|---|---|
diagnostics_severity | "error" | Minimum severity that labels a run “dirty” (vs “clean”). One of "error", "warning", "info", "hint". A status label only — the run always exits 0 and prints every diagnostic (see catenary diagnostics); it no longer gates an exit code. |
Output is complete every time — there is no per-page budget, truncation, or overflow report file.
Linters
catenary diagnostics is a multi-feeder aggregator: alongside the LSP
feeder it runs standalone linters over the same modified-file set and
merges their findings into one deduplicated view. A linter is one-shot
(spawn → parse → exit), routed by root-relative path glob (plus an optional
shebang list), and its output is translated into the same LSP-shaped
diagnostics the language servers produce — so the merge/dedup pass runs
feeder-blind.
Each linter is a [linter.rule.<name>] entry. The adapter that parses its
output is picked by the name: shellcheck, actionlint, and yamllint
use hand-rolled parsers; every other name falls to a generic SARIF
adapter (see Custom linters (SARIF)).
Built-in linter defaults
Catenary ships a batteries-included default set (defaults/linters.toml),
inherited by any root that does not customize or disable lint — exactly like
the built-in language servers. Install the tool and it just works; leave it
uninstalled and the linter is skipped (one notify, never a hard error).
| Linter | Routes on | Invocation | Code |
|---|---|---|---|
actionlint | .github/workflows/*.{yml,yaml} | -format '{{json .}}' (JSON) | kind (coarse category) |
yamllint | **/*.{yml,yaml} | -f parsable (text) | trailing (rule) name |
shellcheck | **/*.sh plus shebang sh/bash/dash/ksh | -f json1 (JSON) | SC#### |
These defaults deliberately overlap language-server coverage —
shellcheck runs even though bash-language-server already wraps it. Catenary
owns the aggregator: the same source/code/line from both feeders collapses
to one entry (see Diagnostics), so overlap is dedup’d rather
than avoided by a “disable X when Y” config opinion.
Customize, inherit, disable
Symmetric with the LSP feeder (three states):
- Inherit — omit
[linter.rule.*]; the shipped defaults apply. - Customize — a
[linter.rule.<name>]entry with the same name replaces the built-in default for that name wholesale (no field-level merge), mirroring the[lsp.server.*]replacement semantics. Add a new name to define an additional linter. - Disable — set
disable = trueon a[linter.rule.<name>]to drop that one linter, or set[linter] disable = truein a project.catenary.tomlto drop the whole linter feeder for that root (see Disabling feeders per root).
# ~/.config/catenary/config.toml
# Add a linter Catenary does not ship by default (SARIF adapter, by name).
[linter.rule.hadolint]
command = "hadolint"
args = ["--format", "sarif"]
patterns = ["**/Dockerfile", "**/Dockerfile.*"]
# Replace the default shellcheck wholesale — e.g. to pass extra flags.
[linter.rule.shellcheck]
command = "shellcheck"
args = ["-f", "json1", "--severity", "warning"]
patterns = ["**/*.sh", "**/*.bash"]
shebangs = ["sh", "bash", "dash", "ksh"]
# Turn off the default yamllint without replacing it.
[linter.rule.yamllint]
disable = true
[linter.rule.*] fields
| Field | Type | Description |
|---|---|---|
command | string | The executable to run (required). |
args | list | Arguments passed before the file paths. |
patterns | list | Root-relative path globs selecting which files this linter handles. Not filename globs — an unanchored *.yaml would fire on every YAML in the tree. |
shebangs | list | Interpreter basenames (["bash", "sh"]) that additionally route an extensionless script by its #! line. Empty ⇒ shebang routing off. |
disable | bool | Drops this linter for the root it resolves under (default false). |
weight | integer | Diagnostic trust weight for this linter’s source, driving the cross-feeder dedup keeper (see Diagnostics). Absent ⇒ the baseline weight. |
The linter is invoked as command <args…> <file…> — the matching file
paths are appended after args. Exit status is ignored: linters exit
nonzero when they find issues, so the adapters key on parseable output, not
on the exit code.
Shebang routing
patterns are path globs, but a shell script often carries no extension —
just a #!/usr/bin/env bash line. A linter that declares shebangs also
routes an extensionless file whose interpreter basename is in the list, reusing
the same #! detection as language classification (#!/usr/bin/env bash and
#!/bin/bash both resolve to bash). The read is lazy — consulted only when
the path globs miss — so a .sh match never touches the file. The default
shellcheck ships ["sh", "bash", "dash", "ksh"], mirroring shellcheck’s own
supported interpreters (notably not zsh, which it rejects).
Custom linters (SARIF)
Any [linter.rule.<name>] whose name is not one of the blessed adapters is
parsed as SARIF (runs[].results[]: tool.driver.name → source, ruleId
→ code, region → range, level → severity, message.text → message). One
adapter covers every SARIF-emitting linter — there is no generic errorformat
engine. A tool that does not speak SARIF is wrapped by the user to emit it
(often a one-line --format sarif).
# A non-default SARIF-emitting linter.
[linter.rule.ruff]
command = "ruff"
args = ["check", "--output-format", "sarif"]
patterns = ["**/*.py"]
Icons
The [icons] table controls icons in the TUI dashboard.
| Preset | Description |
|---|---|
unicode (default) | Safe symbols for any terminal font. |
nerd | Nerd Font glyphs (requires a patched font). |
[icons]
preset = "nerd"
Notifications
Catenary tells you when something needs attention through three channels,
split by urgency. The store-and-forward systemMessage notification queue —
which accumulated warns and drained them into a hook response one turn later —
retired in the TUI rework: it structurally delivered stale truths (a warn could
arrive minutes after the problem was already resolved), and a state-based
health surface cannot (a fixed problem simply isn’t there).
The channels
Every tracing::warn!() and tracing::error!() in Catenary carries an
operational signal. LoggingServer — Catenary’s central tracing subscriber —
dispatches each event by severity:
| Severity | Desktop notification | TUI health surface | Firehose |
|---|---|---|---|
error!() | ✓ (urgent interrupt) | ✓ (a finding) | ✓ |
warn!() | — | ✓ (a finding) | ✓ |
info!() / debug!() | — | — | ✓ |
- Desktop notifications (
src/notify.rs,DesktopNotificationSink) fire an OS-level notification for error-severity events only — the urgent interrupt, the daemon speaking for itself with no host conversation required. Deduped per daemon lifetime. Suppress with[notifications] desktop = falseor theCATENARY_NOTIFY=0environment variable. - The TUI health surface carries everything user-actionable, warns
included: a warn is a health finding (stale hooks, version skew, coverage
degradation). Findings persist on the dashboard’s problems pane until the
problem is fixed, rather than scrolling by in a transcript.
catenary doctorrenders the same findings one-shot. - The firehose (
src/logging/jsonl_sink.rs) records every event, queryable after the fact withcatenary query.
Server-forwarded LSP window messages (window/logMessage and
window/showMessage, tagged source = lsp.logging) are firehose-only and never
a desktop interrupt, regardless of severity — a language server’s own chatter,
including a showMessage type 1 that maps to error, is not Catenary’s own
user-actionable event. It surfaces on the TUI’s secondary Activity/Alerts
surface (see CatenaryInternal misc 125).
The parent-agent context leg
One notice keeps an agent-facing delivery: when a subagent stops leaving a
dirty worktree, Catenary keeps the worktree (never auto-deleting unlanded work)
and the actionable audience is the parent agent that spawned it. That
notice rides Claude Code’s hookSpecificOutput.additionalContext on the
parent’s next eligible hook response (PreToolUse or Stop when allowing),
delivered from a per-session queue that is dropped on session end. It is not a
user notification — the user leg retired with the queue (misc 151).
Configuration
The [notifications] section has a single knob:
[notifications]
desktop = true # default — OS notifications for error-severity events
There is no severity threshold: it was the floor of the retired queue. Warns
are not severity-tunable — they persist on the dashboard, always. A leftover
threshold key does not break startup (it is ignored) but is flagged by the
unknown-key health finding, so catenary doctor and your editor point it out.
Doctor and the TUI: one model, two renderers
catenary doctor is the standalone one-shot renderer of the health model —
scriptable, greppable, and daemon-down capable (it feeds the model with its
own initialize probes); the TUI is its live twin, rendering the same findings
continuously from the daemon’s state.json.
CLI & Dashboard
Dashboard (TUI)
Running catenary in an interactive terminal launches the TUI dashboard.
When stdin and stdout are pipes (launched by an MCP client), it serves
MCP instead — no flags needed.
The dashboard is the primary way to answer “is it working?” at a
glance. It reads a daemon-owned state.json snapshot plus the health
model’s findings and renders a 2×2 master-detail grid: the
Servers (by root) tree (top-left, grouped by root, healthy fleets
collapsed to one line each), the Sessions (by client) tree
(bottom-left, grouped by client with capability-aware session status),
a contextual Details (Servers / Sessions) pane (top-right — titled
for the focused tree: config / routing / findings with provenance /
session actions for the cursored node), and the problems pane
(bottom-right — the durable notification surface, every finding with its
fix-it). There is no header strip: the Problems pane title carries the
one-line verdict (● working / ✗ N problems · M suggestions), and the
footer carries the daemon pid, version + skew, and snapshot freshness. It
is a pure file reader — it never connects to the daemon, probes an LSP, or
opens the firehose. Full protocol and trace history streams to an
append-only JSONL telemetry firehose, which catenary query reads after
the fact.
catenary # launch dashboard
Keybindings
Navigation is keyboard-first; mouse click is an equal path (click a pane to focus it, click a row to select/expand, click a problem to jump to its owner):
| Key | Action |
|---|---|
j / Down | Move down one entry |
k / Up | Move up one entry |
Tab | Focus the next pane |
Shift+Tab | Focus the previous pane |
Enter | Expand/collapse a node, or focus a problem’s owner |
p | Problems-only — collapse both trees to broken things |
d | Toggle the dormant-server inventory |
g / Home | Jump to the first entry |
G / End | Jump to the last entry |
PageDown / PageUp | Page down / up |
y | Yank the selected entry (scope id / text) via OSC 52 |
? | Toggle the keybinds help panel |
q | Quit |
Protocol Transparency
Catenary logs every protocol message — every MCP exchange, every LSP
request and response, every hook invocation — to an append-only JSONL
telemetry firehose, sharded per session, server, and tool invocation:
what Catenary sends to your language servers, what they send back, and
how long each exchange takes. catenary query reads it after the fact;
the TUI renders a live snapshot of the resulting state.
You can see exactly what Catenary does. Nothing is hidden.
CLI Commands
catenary grep
Search for a pattern across the workspace. Queries ripgrep and the LSP symbol index in parallel. Results are LSP-enriched within tracked workspace roots. Uses the shell’s current working directory as the search root.
catenary grep "pattern"
catenary grep "foo|bar" "src/**/*.rs"
catenary grep "TODO" --type rust # restrict to a ripgrep file type
catenary grep "fn main" --glob "src/**" # scope which files are searched
catenary grep "TODO" --exclude-pattern "vendor/**"
catenary grep "pattern" --include-hidden --include-gitignored
catenary grep "TODO" --count # "N matches in M files"
Quote glob patterns so Catenary expands them gitignore-aware rather than
the shell. Output is complete every time — no truncation, paging, or spill
files — and composes freely with pipes and redirects (catenary grep p | head works). Ask for a total with --count, narrow with --type or
--glob, and exclude with --exclude-pattern.
| Flag | Description |
|---|---|
[PATH]... | File or directory path(s) to scope the search (quoted globs allowed) |
--glob <pat> / -g | Include only files matching this glob (repeatable; !pat excludes) |
--type <ty> / -t | Include only files of this ripgrep type, e.g. rust, md (repeatable) |
--exclude-pattern <pat> | Glob pattern to exclude from matches |
--ignore-case / -i | Case-insensitive matching (overrides smart-case) |
--case-sensitive / -s | Case-sensitive matching (overrides smart-case) |
--word-regexp / -w | Match whole words only |
--fixed-strings / -F | Treat the pattern as a literal string, not a regex |
--invert-match / -v | Select non-matching lines |
--files-with-matches / -l | Print only the paths of files containing a match |
--after-context <n> / -A | Show n lines of context after each match |
--before-context <n> / -B | Show n lines of context before each match |
--context <n> / -C | Show n lines of context before and after each match |
--count / -c | Report the match count instead of results |
--include-gitignored | Include files ignored by .gitignore |
--include-hidden | Include hidden files and directories |
catenary glob
Browse the workspace: file outline, directory listing, or glob pattern match. Auto-detects intent from each PATH — a file path shows a symbol outline, a directory path shows a listing with symbols, and a glob pattern shows matching files.
A PATH may be a glob pattern: quote it so the shell doesn’t expand it
and Catenary walks it gitignore-aware instead. Patterns may be absolute or
cwd-relative, and the anchor belongs in the pattern — there is no
separate directory argument (catenary glob 'src/**/*.rs',
catenary glob '/abs/dir/**/*.md'). Each pattern argument’s results open
with a one-line cardinality header — N files match <pattern> (singular
grammar for one) — printed before the per-file listings, so a
| head-truncated view still shows the true count. A pattern that expands
to nothing is never silent either: it reports no matches for pattern: <pattern> (relative patterns anchor at cwd), per argument, even when
sibling arguments render. (Directory and single-file arguments render
unchanged — a directory shows its own structure, a named file is its own
answer.)
The outline is a map, not a mirror — it renders types and callables only. It recurses into containers (modules/namespaces/packages and classes/interfaces/enums/structs/impls), showing the containers and their functions, methods, and constructors. Data members (fields, properties, enum variants, and variables/constants below the top level) are pruned, and a callable’s interior (locals, loop vars, nested defs) is never entered — each callable is one line. The top level shows everything, so a module-level constant stays. (The underlying symbol index is unfiltered; only the outline render applies this map.)
catenary glob "src/"
catenary glob "src/main.rs"
catenary glob "**/*.toml" # opens "N files match **/*.toml"
catenary glob "**/*.rs" --exclude-pattern "tests/**"
catenary glob "**/*.rs" | head -3 # header shows the true count first
catenary glob "**/*.rs" --count # "N paths"
Like catenary grep, glob emits complete output — pipe or redirect it
freely — and --count answers “how many” without the listing.
| Flag | Description |
|---|---|
[PATH]... | File, directory, or quoted glob pattern(s) — absolute or cwd-relative, anchor in the pattern |
--exclude-pattern <pat> | Glob pattern to exclude from results |
--count | Report the path count instead of results |
--include-gitignored | Include files ignored by .gitignore |
--include-hidden | Include hidden files and directories |
catenary diagnostics
Print LSP diagnostics for the files you’ve edited, or lint the paths you
name. Editing is tracked automatically — the first edit to a
server-covered file starts it, there is no start step. Bare, this command
diagnoses the current batch: it opens every modified file on its server,
waits for each to settle, and prints a per-file receipt — every
diagnosed file listed, its errors and warnings beneath it, or [clean]
beside it when the file is clean. The batch is durable, not consumed: run
bare again with no intervening edit and it re-diagnoses the same set, fresh
(the git status idiom). Your next covered edit after a fully-diagnosed
batch starts a new one. When a file’s
server dies before answering — mid-run, or by failing to start at all —
Catenary makes one bounded, in-run attempt to respawn it and re-run the
remainder (a slight stall, never an unbounded wait); if that fails, coverage
has degraded for this run. A dead server is not abandoned: the next
demand that routes to it (a diagnose or query) revives it, bounded by a
per-server strike counter — each failure (a crash while up, a failed
respawn, a failed initialize) is a strike, each served result pays one
back, and at three strikes the server is benched: no further revives
until the daemon restarts or the root is remounted, so a crash-looping
server never flaps unbounded. Coverage is effective, not nominal: a server
that cannot be brought back means its files owe nothing for this run — the
same class as a file no server covers, because the gap is Catenary’s to
close, never yours. Such a file is neither clean nor dirty; it is listed as
[unverified — <server> returned no result] — or [unverified — <server> stuck; will retry on demand] when process-state evidence types the server
as wedged (respawn-dead, or init-hung so its tick-budgeted initialize
failed): “stuck” is a claim about the process, made only on the evidence. A
benched server’s files carry the terminal cause instead: [broken — <server> never started] (it struck out without ever serving — config or environment;
fix the server) or [unstable — <server> gave up after repeated crashes].
Every state pays: an armed gate is always payable — a stuck or benched
server yields an honest receipt rather than a silent hang, and paying is
diagnosing, not fixing. The receipt opens with a top-line
banner naming the unavailable server (unavailable: <server>) so degraded
never reads as clean — the absence of evidence is not evidence of absence.
An all-unverified run can never render as empty stdout (mistakable for a
hang), and the exit stays 0: the run completed and its receipt is
truthful. The gate releases the degraded file exactly as a paid one — editing
it again re-arms it, and a server that is back next run resumes the normal
contract. When nothing was edited it prints [no edited files].
The batch survives a killed client. A catenary diagnostics run pays its
debt by delivery, not at dispatch: the batch’s per-file flags flip only after
the daemon’s response reaches the CLI. So an invocation killed after dispatch (a
backgrounded command reaped by the host, a tool-call timeout, a Ctrl-C) leaves
the flags unflipped and the gate armed — the batch is intact, and the next bare
run re-diagnoses it in full. A kill after a successful write recovers the same
way: the batch is retained, so re-running bare re-serves it. Recovery is always
“run it again.”
The batch does not survive the daemon. It is in-memory state keyed by
(session_id, agent_id): durable across runs within a daemon instance, but
released when the instance dies (maintainer ruling, bug 79). On daemon death
the debt is dropped, never spooled — a fresh daemon starts with a disarmed gate,
and a bare run answers [no edited files]. This is deliberate: an unstable
daemon must never lock a session out of the shell. The cost is that unpaid debt
across a restart is forgotten silently; the benefit is that a wedged daemon is
always recoverable by restart, never a permanent lockout.
With and without hooks. The batch is populated by the PreToolUse hook,
which tracks every file the agent edits. In a hooked session (the plugin
installed) catenary diagnostics behaves exactly as above — the bare form pays
the tracked batch, and a scoped form pays the named files’ debt. On a
hookless box (no plugin, e.g. a scripted or CI invocation, or a bare shell)
there is no tracked batch, so the two forms split:
- Bare
catenary diagnosticsis the gate verb, and there is no gate to pay: it errors with a teaching message and exits2(a fault, not a clean empty receipt). Naming what you want diagnosed is the fix. - Scoped
catenary diagnostics <path…>— includingcatenary diagnostics .— has no debt to settle, so it simply serves the diagnostics on demand: it diagnoses the named paths (mounting an enclosing project root ephemerally when needed) and prints the receipt, with no gate machinery. This is the CLI-only lint surface —doctor→pin→diagnostics .works with no host plugin at all.
catenary diagnostics # the whole edited set (hooked)
catenary diagnostics src/main.rs # lint one file on demand
catenary diagnostics src/ lib.rs # a scoped set (relative to cwd)
catenary diagnostics . # the whole workspace root
Whole-root scope (.). Naming a directory lints every covered file
beneath it; naming a whole tracked workspace root (.) lints the entire
project. When the covering language server advertises whole-workspace pull
(workspace/diagnostic), Catenary serves . with one request off the
server’s existing project model — no per-file open/close churn, and it
surfaces cross-file diagnostics a per-file pull can miss. A server without
that capability, or any sub-root directory, falls back to the per-file
pass (identical results, more work). Because a whole-root run can span many
files, the receipt collapses the clean files to a count (N files clean) — and likewise any unverified files (M files unverified) — and
lists only the files that have diagnostics — the complete diagnostics still
print in full; only the clean and unverified lists are folded. The
edit-loop receipt (a handful of files) stays per-file, with [clean] or the
[unverified — …] line beside each.
The edit gate is a debt paid by diagnosing, not fixing. Every
server-covered file you edit joins the batch; each file’s debt is paid
by looking at it — pulling its diagnostics, clean or dirty — after which
you choose whether to fix. Bare pays the whole batch at once (it diagnoses
every file, delivered or not, so a later edit’s cross-file effects surface).
Naming paths pays exactly those: a partial pull leaves the gate armed
for the files you didn’t name, so the command filter keeps blocking unrelated
commands until the rest are diagnosed. Editing a
paid file re-arms it. A named path that was never edited is simply linted
on demand — it pays nothing, since it owed nothing. Relative paths resolve
against the shell’s current working directory. A named path that does not
exist, or that resolves outside every mounted root, is never dropped in
silence. When the path has a detectable enclosing project root (walking
.git up from it), Catenary mounts that root ephemerally and diagnoses
the file from the freshly-attached server — the mount then expires after a
few minutes of inactivity (or catenary pin pins it). When no
enclosing root is detectable, the receipt names the path on its own line and
says why (path does not exist, or that it is outside every mounted root).
catenary diagnostics is a load-bearing command — run it (bare or scoped)
as its own step (no pipes, no &&/; chaining), and read the result.
The exit code is a trust signal, not a lint result: it exits 0
whenever the run completed — clean or dirty — and 2 only on a genuine
fault (no daemon, IPC failure, or a bare hookless run with no gate to pay).
It never exits 1, so a run that found errors is not mistaken for a failed
call — read the receipt for the errors, not the exit code. (Whether a run is
labeled “dirty” is tunable via diagnostics_severity in
Configuration, but that is a status label only
and does not change the exit code.)
catenary query
Query the JSONL telemetry firehose — every LSP, MCP, and hook message, plus internal trace events. Reads the append-only logs directly, so it works even when the daemon is down. Useful for debugging and bug reports.
Filters fall into two groups. File-selection filters pick which shards
to read: --session (one session’s log), --server (an LSP server),
--tool (a grep/glob invocation). Record filters apply after open:
--cwd, --since, --level, --kind, and --search.
catenary query --session 029ba740 --since 1h
catenary query --kind hook --since today
catenary query --search "timeout" --format json
catenary query --server rust-analyzer --level warn --follow
| Flag | Description |
|---|---|
--session <id> | Read one session’s log (id or prefix) |
--server <name> | Read an LSP server’s log (all instances) |
--tool <grep|glob> | Read a search tool’s invocation log |
--cwd <path> | Keep records whose cwd is this path or under it |
--since <dur> | Time filter (1h, today, 7d, 30m) |
--level <lvl> | Minimum severity (error/warn/info/debug) |
--kind <kind> | Record kind (lsp/mcp/hook/internal) |
--search <text> | Free-text substring over method, message, payload |
--instance <id> | Read a specific daemon instance dir (default: freshest) |
--all-instances | Read every instance dir, not just the freshest |
--follow | Live-tail the selected files |
--limit <n> | Max rows (0 = unlimited; default 100) |
--format <fmt> | Output format: table (default) or json |
catenary pin / catenary unpin / catenary roots
Manage workspace-root lifetime. Coverage is automatic — Catenary mounts and serves the workspace for you — so these change only how long a root lives, not whether it is served.
catenary pin /path/to/project # stop idle expiry, pre-warm servers, upgrade an ephemeral mount
catenary unpin /path/to/project # drop the pin added by `catenary pin`
catenary roots # list the current roots with their contributor classes
catenary pin adds the pin contributor and pre-warms the root’s language
servers; on an activity-mounted (ephemeral) root it upgrades the mount to pinned
so it stops expiring. catenary unpin removes only the pin contributor,
matching the stored/normalized path — so it works even after the directory has
been deleted, and repeating it is a harmless no-op. The worktree, ephemeral, and
mcp: contributor classes own their own lifecycles and are untouched. Bare
catenary roots lists the current roots (catenary roots ls is a kept alias).
The old catenary roots add / catenary roots rm spellings are retired: use
catenary pin / catenary unpin.
catenary worktree
Manage Catenary-created worktrees — the sanctioned replacement for git worktree (which the agent surface denies). A worktree is a durable, isolated
checkout of a branch that language servers index like any other root, so you can
prepare a change in isolation and land it when it is ready.
catenary worktree add my-feature # create a feats-class worktree for a branch
catenary worktree ls # list Catenary-managed worktrees
catenary worktree diff <path> # print the worktree's full diff vs HEAD
catenary worktree land <path> # apply + stage the changes, then retire the worktree
catenary worktree rm <path> # remove a worktree
| Subcommand | Description |
|---|---|
add <branch> [path] | Create a durable worktree for <branch> (default path under Catenary’s state dir; pass an explicit path to override). Adds a sibling symlink for discovery. |
ls | List Catenary-managed worktrees — path, class, creator, age, clean/dirty, and (for feats worktrees) ahead/behind counts. |
diff <path> | Print the worktree’s complete diff vs HEAD — tracked changes plus untracked files as new-file hunks — as a valid git apply patch. --name-only prints just the changed paths. |
land <path> | Apply the worktree’s diff into the owning repo with git apply --3way, stage the result, arm a diagnostics batch over the changed files, delete the branch, and retire the root. It never commits — you review and commit. --keep lands without removing the worktree. |
rm <path> | Remove a worktree class-appropriately. A dirty worktree is never auto-reaped — rm refuses to discard uncaptured work. |
land stages but does not commit, so the changes land in your index for review.
A dirty worktree is never removed automatically: unlanded work is always kept
until you land or explicitly remove it.
catenary doctor
Verify language servers and hook installation. See Installation.
Pass a server name for verbose single-server diagnostics:
catenary doctor rust-analyzer
Verbose mode prints the resolved command, binary path, stderr capture,
full initialize request/response JSON, and capabilities list.
| Flag | Description |
|---|---|
[server] | Server name for verbose single-server mode (matches [lsp.server.*] keys) |
--root <path> | Workspace root to probe and read .catenary.toml from (default: cwd) |
--diff | Show a unified diff for every stale host file (hooks.json, the constrained-bash helper) |
--nocolor | Disable colored output |
catenary start / catenary stop
catenary start brings the daemon up explicitly — the counterpart to stop.
It is idempotent: if a daemon is already running it connects, reports that, and
leaves it running. You rarely need it, because the bridge starts (and
transparently reconnects) the daemon on demand; it exists so a manual stop or
a killed daemon has a one-command remedy without a per-session /mcp reconnect.
catenary start # bring the daemon up (idempotent)
catenary stop stops the running daemon. When you run it in an interactive terminal and
sessions are still connected, it 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.
catenary stop # confirm before disconnecting live sessions
catenary stop --force # skip the prompt (scripts, upgrade flow)
--force skips the prompt, and a non-interactive stdin skips it too, so
scripts and the documented upgrade flow are unaffected. After the stop, a
warning names how many sessions lost tooling — each needs a /mcp reconnect,
since a host restart alone won’t respawn the daemon.
| Flag | Description |
|---|---|
--force | Stop without the confirmation prompt, even with live sessions |
catenary version
Print the CLI version and the running daemon’s version. catenary --version (the clap flag) prints only the binary’s own version instantly, with
no daemon I/O; the version subcommand additionally queries the daemon, so it
surfaces version skew — a daemon lags a freshly rebuilt CLI until it is
restarted, and this shows that at a glance.
catenary --version # this binary only (instant)
catenary version # this binary + the running daemon
catenary update
Self-update the catenary binary from the latest GitHub release for your
platform (catenary-linux-amd64, catenary-macos-arm64,
catenary-windows-amd64). There is no Intel-mac asset — build from source on
Intel hardware.
catenary update # download and replace the binary if newer
catenary update --check # report whether an update is available, download nothing
catenary update --force # re-download even when versions already match
| Flag | Description |
|---|---|
--check | Print whether an update is available without downloading |
--force | Re-download even if the installed version already matches |
Language Servers
Setup guides for individual language servers. Each page covers installation and Catenary configuration.
Languages
| Language(s) | Page | Server |
|---|---|---|
| CSS, HTML, JSON | CSS-HTML-JSON | vscode-langservers-extracted |
| Go | Go | gopls |
| JavaScript | JavaScript | typescript-language-server |
| Julia | Julia | LanguageServer.jl |
| Markdown | Markdown | lattice |
| PHP | PHP | intelephense |
| Python | Python | pyright-langserver |
| Rust | Rust | rust-analyzer |
| Shell (Bash) | Shell | bash-language-server |
| Termux & Packaging | Termux | termux-language-server |
| TypeScript | TypeScript | typescript-language-server |
Contributing
Want to add a language?
- Create a page for your language in the
lsp/folder following the template below - Add a row to the table above
- Submit a PR
Template
# YourLanguage
## Install
### macOS
```bash
# install command
```
### Linux
```bash
# install command
```
### Windows
```bash
# install command
```
## Config
Catenary ships a built-in definition for `your-language-server` — no
`[lsp.server.*]` config is needed. If `your-language-server` is on PATH,
it works automatically.
<!-- OR, if the server is not in the built-in defaults: -->
Add to `~/.config/catenary/config.toml`. The `[lsp.server.*]` section key
**is** the server binary Catenary spawns (add `path = "/abs/path"` only to
relocate a binary that is not on PATH):
```toml
[lsp.server.your-language-server]
args = ["--stdio"]
[lsp.language.yourlanguage]
servers = ["your-language-server"]
```
## Notes
Any gotchas, tips, or links to official docs.
CSS, HTML, JSON
These three languages are bundled together in one package: vscode-langservers-extracted.
Install
macOS
npm install -g vscode-langservers-extracted
Linux
npm install -g vscode-langservers-extracted
Windows
npm install -g vscode-langservers-extracted
Config
Catenary ships built-in definitions for vscode-css-language-server,
vscode-html-language-server, and vscode-json-language-server — no
[lsp.server.*] config is needed. If the binaries are on PATH, they work
automatically for CSS, SCSS, Less, HTML, JSON, and JSONC files.
What’s Included
The vscode-langservers-extracted package provides:
| Server | Languages |
|---|---|
vscode-css-language-server | CSS, SCSS, Less |
vscode-html-language-server | HTML |
vscode-json-language-server | JSON, JSONC |
vscode-markdown-language-server | Markdown |
vscode-eslint-language-server | ESLint |
Notes
- These servers are extracted from VS Code, so they’re well-maintained and feature-complete
- SCSS and Less use the same CSS server — it auto-detects the language
- For Tailwind CSS support, use
tailwindcss-language-server(separate server)
Links
Go
Install
macOS
go install golang.org/x/tools/gopls@latest
Or via Homebrew:
brew install gopls
Linux
go install golang.org/x/tools/gopls@latest
Windows
go install golang.org/x/tools/gopls@latest
Config
Catenary ships a built-in definition for gopls — no [lsp.server.*]
config is needed. If gopls is on PATH, it works automatically.
Notes
goplsis the official Go language server- Ensure
$GOPATH/bin(or$HOME/go/bin) is in your PATH - Works with Go modules out of the box
- First run indexes your module cache — may take a moment
Links
JavaScript
JavaScript uses the same language server as TypeScript.
Install
macOS
npm install -g typescript typescript-language-server
Linux
npm install -g typescript typescript-language-server
Windows
npm install -g typescript typescript-language-server
Config
Catenary ships a built-in definition for typescript-language-server — no
[lsp.server.*] config is needed. If typescript-language-server is on
PATH, it works automatically.
Notes
- Same server as TypeScript — install once, configure both
- Works with
.js,.jsx,.mjs,.cjsfiles - Provides type inference even in plain JavaScript
- Add a
jsconfig.jsonto customize project settings
JSX / React
JSX is handled automatically. The built-in defaults bind
typescript-language-server to javascriptreact for .jsx files.
Links
Julia
Install
macOS / Linux / Windows
From the Julia REPL:
using Pkg
Pkg.add("LanguageServer")
Config
Catenary ships a built-in definition for julia — no [lsp.server.*]
config is needed. If julia is on PATH and LanguageServer.jl is
installed, it works automatically.
Notes
- The server starts a Julia process, which has some startup time
- The built-in uses
--startup-file=noand--history-file=nofor faster startup - First run on a project may take time to load packages and index
- Works best with projects that have a
Project.toml
Reducing Startup Time
For faster startup, you can create a custom sysimage:
using PackageCompiler
create_sysimage([:LanguageServer], sysimage_path="languageserver.so")
Then override the built-in default:
[lsp.server.julia]
args = ["--sysimage=/path/to/languageserver.so", "-e", "using LanguageServer; runserver()"]
Links
Markdown
Catenary’s out-of-box markdown server is
Lattice — a Two Wells sibling project
(same AGPL-3.0-or-later + commercial dual-license as Catenary). Lattice is a
markdown predicate linter / backlink reconciler shipped as an LSP server. It
answers documentSymbol, workspace symbols, references, rename, hover,
folding, and document links, so it powers Catenary’s catenary grep /
catenary glob enrichment and the catenary diagnostics pipeline on markdown.
Install
Lattice ships as the lattice binary. Install it from the
Lattice repository (build from source
with cargo install, or grab a release binary) and put lattice on your PATH.
# From a checkout of TwoWells/Lattice
cargo install --path .
Verify it is reachable:
lattice --version
Config
Catenary ships a built-in definition for lattice — no [lsp.server.*] config is
needed. If lattice is on PATH it works automatically:
[lsp.server.lattice]
args = ["serve"]
Root markers
The default markdown root_markers are [".lattice.toml", ".git"]:
.gitroots Lattice at the repository — correct, since its backlink graph spans the repo’s markdown..lattice.toml(optional) gives a tighter root and project configuration when present, even in a subdirectory.
Model
Lattice treats a markdown tree as a predicate/backlink graph:
- Predicates live in CommonMark title text.
- Backlinks live in YAML frontmatter.
catenary diagnostics surfaces Lattice’s link/predicate checks on edited
markdown, and grep/glob enrichment exposes the document structure Lattice
reports.
Opting out (marksman)
marksman remains a shipped server
definition — it is simply no longer the default. Re-enable it with a one-line
binding (no need to redefine the server), in either your user config
(~/.config/catenary/config.toml) or a project .catenary.toml at a
workspace root:
[lsp.language.markdown]
servers = ["marksman"]
If marksman is on PATH, that binding is all you need. Both layers reach server
dispatch: the user binding reroutes everywhere, and a project .catenary.toml
binding reroutes that root — the project layer wins per root. A project
[lsp.language.*] servers list replaces the binding (array-replace, never
append), and a server the project defines in its own [lsp.server.*] is a legal
binding target.
Notes
- OOB markdown intelligence requires
latticeon PATH. Without it the daemon emits a benignFailed to spawn LSP server: latticewarning — not a wedge; every other server and all of Catenary’s core (grep/glob/diagnostics on other languages) is unaffected.
Links
PHP
Install
Intelephense is the most popular PHP language server.
macOS
npm install -g intelephense
Linux
npm install -g intelephense
Windows
npm install -g intelephense
Config
Catenary ships a built-in definition for intelephense — no
[lsp.server.*] config is needed. If intelephense is on PATH, it works
automatically.
Notes
- Intelephense has a free tier and a premium tier with additional features
- The free tier includes: completions, hover, definitions, references, diagnostics, formatting
- Premium adds: rename, code actions, go to implementation
- Works great with Laravel, Symfony, WordPress, and vanilla PHP
Alternatives
phpactor
A free, open-source alternative:
# Install via composer
composer global require phpactor/phpactor
[lsp.server.phpactor]
args = ["language-server"]
[lsp.language.php]
servers = ["phpactor"]
Links
Python
Install
Pyright is a fast, feature-rich Python language server from Microsoft.
macOS
npm install -g pyright
Or via Homebrew:
brew install pyright
Linux
npm install -g pyright
Windows
npm install -g pyright
Config
Catenary ships a built-in definition for pyright — no [lsp.server.*]
config is needed. If pyright-langserver is on PATH, it works
automatically.
To customise settings, add to ~/.config/catenary/config.toml:
[lsp.server.pyright-langserver.settings.python]
pythonPath = "/usr/bin/python3"
[lsp.server.pyright-langserver.settings.python.analysis]
exclude = ["**/target", "**/node_modules"]
extraPaths = []
Settings
Pyright requests configuration via workspace/configuration. Use the settings
table on the [lsp.server.*] entry to provide Python interpreter paths, analysis
exclusions, and other options (shown above).
Without these settings, pyright may fall back to scanning the entire workspace
(including large directories like target/ or node_modules/), which can
cause extremely slow initialization.
See the Pyright configuration docs for the full list of available settings.
Notes
- Pyright provides type checking even for untyped code (infers types)
- Works well with virtual environments — activate your venv before starting your MCP client
- For Django/Flask projects, Pyright handles most patterns out of the box
Alternatives
Pylsp (python-lsp-server)
A community-maintained server with plugin support:
pip install python-lsp-server
[lsp.language.python]
servers = ["pylsp"]
No [lsp.server.pylsp] block is needed — the section key pylsp is the
binary Catenary spawns; add one with path = "/abs/path" only to relocate it.
Jedi Language Server
Lightweight, uses Jedi for completions:
pip install jedi-language-server
[lsp.language.python]
servers = ["jedi-language-server"]
The section key jedi-language-server is the binary Catenary spawns, so no
[lsp.server.*] block is needed — add one with path = "/abs/path" only to
relocate a binary that is not on PATH.
Links
Rust
Install
macOS
rustup component add rust-analyzer
Linux
rustup component add rust-analyzer
Windows
rustup component add rust-analyzer
Config
Catenary ships a built-in definition for rust-analyzer — no [lsp.server.*]
config is needed. If rust-analyzer is on PATH (the rustup proxy installed
by rustup component add rust-analyzer provides it), it works automatically.
To customise, add to ~/.config/catenary/config.toml:
[lsp.server.rust-analyzer]
env = { CLIPPY_DISABLE_DOCS_LINKS = "1" }
[lsp.server.rust-analyzer.initialization_options]
check.command = "clippy"
cargo.features = "all"
diagnostics.disabled = ["inactive-code"]
Setting CLIPPY_DISABLE_DOCS_LINKS=1 strips “for further information
visit …” suffixes from clippy diagnostics, saving agent context
tokens.
Notes
- rust-analyzer is the official Rust language server
- Installing via rustup ensures it stays in sync with your Rust toolchain
- The
rust-analyzeron PATH is the rustup proxy, which dispatches to the toolchain’s own rust-analyzer — so it tracks a project-levelrust-toolchain.tomlautomatically - First run on a project may take time to index (watch for “Indexing” status)
Links
Shell (Bash)
Install
macOS
npm install -g bash-language-server
Linux
npm install -g bash-language-server
Windows
npm install -g bash-language-server
Config
Catenary ships a built-in definition for bash-language-server — no
[lsp.server.*] config is needed. If bash-language-server is on PATH, it
works automatically.
Notes
- The language ID is
shellscript, notbashorsh - Works with
.sh,.bash,.zshfiles - Provides completions for commands, variables, and functions
- Integrates with ShellCheck for linting (install separately)
Multi-server: PKGBUILD Files
For PKGBUILD and other packaging scripts, combine bash-language-server
with termux-language-server for enhanced support:
[lsp.server.termux-language-server]
args = ["--stdio"]
file_patterns = ["PKGBUILD", "*.ebuild"]
[lsp.language.shellscript]
servers = ["termux-language-server", "bash-language-server"]
termux-language-server is tried first for PKGBUILD and ebuild files, with
bash-language-server filling in for methods termux doesn’t handle. See
Dispatch Filtering
for details on file_patterns.
Optional: ShellCheck Integration
For better diagnostics, install ShellCheck:
# macOS
brew install shellcheck
# Linux (Debian/Ubuntu)
apt install shellcheck
# Linux (Arch)
pacman -S shellcheck
The language server will automatically use it if available.
Links
Termux & Packaging
The termux-language-server provides advanced support for specialized shell scripts used in Termux, Arch Linux (PKGBUILD), Gentoo (ebuild), and Debian development.
Install
macOS
pip install termux-language-server
Linux
pip install termux-language-server
Windows
pip install termux-language-server
Config
As a supplementary server (recommended)
Use file_patterns to add termux-language-server alongside
bash-language-server for packaging files:
[lsp.server.termux-language-server]
args = ["--stdio"]
file_patterns = ["PKGBUILD", "*.ebuild", "*.eclass"]
# bash-language-server is built-in — no [lsp.server.bash-language-server] needed
[lsp.language.shellscript]
servers = ["termux-language-server", "bash-language-server"]
termux-language-server handles PKGBUILD and ebuild files with
package-specific intelligence. bash-language-server provides shell
fundamentals (definition, references, symbols) for all shellscript files. For
PKGBUILD files, termux-language-server is tried first; bash-language-server
fills in for methods it doesn’t handle. See
Dispatch Filtering.
As a standalone server
For the full set of termux-language-server language IDs, define each as a custom language:
[lsp.server.termux-language-server]
args = ["--stdio"]
[lsp.language.pkgbuild]
filenames = ["PKGBUILD"]
servers = ["termux-language-server"]
[lsp.language.ebuild]
extensions = ["ebuild"]
servers = ["termux-language-server"]
[lsp.language.eclass]
extensions = ["eclass"]
servers = ["termux-language-server"]
Add entries for other language IDs as needed (termux, makepkg,
devscripts, mdd, subpackage, install, gentoo-make-conf,
make.conf, color.map).
Notes
- This server is specifically designed for packaging and system-level shell scripts
- Extends the features of
bash-language-serverfor specialized formats - Supports file types like
PKGBUILD,build.sh,*.ebuild, and*.mdd
Links
TypeScript
Install
macOS
npm install -g typescript typescript-language-server
Linux
npm install -g typescript typescript-language-server
Windows
npm install -g typescript typescript-language-server
Config
Catenary ships a built-in definition for typescript-language-server — no
[lsp.server.*] config is needed. If typescript-language-server is on
PATH, it works automatically for TypeScript, JavaScript, TSX, and JSX
files.
Notes
- The same server handles both TypeScript and JavaScript (see JavaScript)
- Requires
typescriptas a peer dependency - Works with
.ts,.tsx,.mts,.ctsfiles - Reads your
tsconfig.jsonfor project settings
Links
Architecture
Catenary is a multi-surface intelligence router. A single daemon manages a pool of LSP servers and exposes them through four decoupled interfaces: MCP (connection), hooks (enforcement), CLI (queries and editing lifecycle), and TUI (observability). All interfaces share the same LSP server pool. None depends on the others.
Four surfaces
Every external interaction crosses one of four boundaries:
- CLI — agent ↔ Catenary. The agent invokes CLI commands via the
host’s shell tool:
catenary grep,catenary globfor search;catenary diagnosticsfor the batched diagnostic report (editing is tracked implicitly — the first edit starts it, there is no start step). Mass edits go through nativesed -i— the host writes and the hook resolves and tracks the write-set. Commands connect to the daemon over a Unix domain socket, send a request, and print the result to stdout. - MCP — agent ↔ Catenary. A pure connection surface for session management and workspace root discovery. No application-level tools.
- LSP — Catenary ↔ language servers. Catenary spawns and manages language server processes, sending requests and receiving notifications over JSON-RPC stdio.
- Hooks — host CLI ↔ Catenary. The host CLI (Claude Code, Antigravity CLI) fires hooks at lifecycle boundaries (pre-tool, post-agent, session start/end). Hook processes connect to the daemon’s IPC socket and exchange JSON messages.
Multiplexing
A single Catenary session can manage multiple language servers across multiple workspace roots. Files route to the right server(s) based on language detection, configuration, and server capabilities. A Rust file goes to rust-analyzer; a TypeScript file goes to typescript-language-server. If a language has multiple configured servers, Catenary dispatches to all of them and merges results.
Hexagonal structure
Catenary follows a port/adapter pattern. Three boundary components own all protocol logging:
McpServer— MCP protocol adapter. Handles the MCP lifecycle (initialize, roots, ping) over JSON-RPC. No application-level tools — grep and glob are served via CLI commands over the IPC socket.LspClient— LSP protocol adapter. One instance per language server process. Manages the JSON-RPC connection, document state, and capability negotiation.HookServer— Hook protocol adapter. Listens on an IPC socket, dispatches hook requests, returns responses.
LoggingServer is the telemetry port. It is a tracing Layer
that dispatches every event to its sinks: the append-only JSONL telemetry
firehose (the full audit trail, read by catenary query) and the
desktop-notification sink (error severity — the urgent interrupt). Warn/error
events additionally feed the alert ring of the daemon-owned state.json
snapshot (the TUI health surface). Every protocol message flows through it.
Application servers (GrepServer, GlobServer, DiagnosticsServer)
are the transformation layer. They receive application-level parameters
from the IPC router, do work using LspClient, and return results.
They do not log protocol messages — that is the boundary components’
job. An application server is a black box: the protocol messages that
went in and came out are linked by parent_id in the firehose records.
Component diagram
┌─────────────────────────────────────────────────────┐
│ Catenary daemon │
│ │
Agent ◄──CLI──► │ IPC router ──► GrepServer / GlobServer / │
(grep, glob, │ DiagnosticsServer │
diagnostics) │ │ │
│ LspClientManager │
│ ┌─────┴──────┐ │
│ LspClient LspClient │
│ │ │ │
└─────────────────┼────────────┼──────────────────────┘
│ │
LSP (stdio) LSP (stdio)
│ │
rust-analyzer typescript-
language-server
Agent ◄──MCP──► McpServer (session management, roots discovery, ping)
Host CLI ◄──IPC──► HookServer ──► HookRouter (editing enforcement,
(hooks) command filtering, file tracking)
LoggingServer (tracing Layer) ─── dispatches all events to sinks:
├── desktop notifications (error severity — the urgent interrupt)
├── state.json snapshot (warn/error → TUI health surface)
└── JSONL firehose (append-only audit trail, read by catenary query)
Shared infrastructure
Session— application container. Owns application servers, the client manager, filesystem manager, editing state, path validation, and logging. Protocol boundaries holdArc<Session>.FilesystemManager— file classification and root resolution. Single authority for language detection, shebang parsing, and workspace root membership. Also owns the per-root mtime baselines behind walk-on-demand change detection — there is no background watcher; the walk a query already performs feeds a precise per-serverworkspace/didChangeWatchedFileschanged-set (see Document Lifecycle & File Watching).LspClientManager— LSP server lifecycle. Spawns, caches, and shuts downLspClientinstances. Manages instance keying (language, server name, scope), multi-server routing, document lifecycle, and workspace folder synchronization.- State & storage — there is no primary database. Live state (the
session and server boards, recent alerts and activity) lives in a
daemon-owned
state.jsonsnapshot underruntime_dir, rewritten on change; the full protocol and trace history streams to an append-only, sharded JSONL firehose undercache_dir, read bycatenary query. The durable Unix sockets live understate_dir. A legacycatenary.dbfrom older versions is deleted on daemon startup. Seesrc/paths.rs.
Topic pages
- Session Lifecycle — startup, serving, root addition, and shutdown.
- Configuration Model — config sources, layering, language/server split, project config.
- Routing & Dispatch — file classification, instance keying, multi-server dispatch.
- LSP Client Layer — connection, server, client, capabilities, settle/idle detection.
- Document Lifecycle & File Watching — document sync, editing mode, file watcher notifications.
- Logging, Hooks & TUI — tracing pipeline, hook integration, monitor dashboard.
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>).
Configuration Model
This page explains the design behind Catenary’s configuration system: why it is structured the way it is, how layers compose, and what tradeoffs were made. For syntax reference and usage examples, see the Configuration guide.
Why the language/server split
Early Catenary configs merged everything into [lsp.language.*] entries —
each language carried its own command, args, settings, and server
identity. This worked when the mapping was one-to-one: one language, one
server.
Two scenarios broke it:
-
Multiple servers per language. PKGBUILD files are shellscript, but they benefit from both
termux-language-server(package-specific hover, diagnostics) andbash-language-server(shell fundamentals — definitions, references, symbols). A single[lsp.language.shellscript]entry can’t hold two server definitions. -
One server for multiple languages.
clangdserves both C and C++. Under the old model, itscommand,args, andsettingshad to be duplicated across[lsp.language.c]and[lsp.language.cpp].
The fix is a relational split:
[lsp.language.*]answers “what” — which servers handle this language, and how files are classified into it.[lsp.server.*]answers “how” — the binary, arguments, initialization options, settings, severity filter, and dispatch filter for a server process.
A [lsp.server.*] entry is defined once and referenced by name from any
number of [lsp.language.*] entries. A [lsp.language.*] entry’s servers
list can reference multiple servers. This is a many-to-many
relationship.
Config layering
Five sources, loaded in order. Later sources override earlier ones on a per-field basis:
-
Default config — an embedded TOML file (
defaults/languages.toml) compiled into the binary. Contains classification data (extensions,filenames,shebangs) for all built-in languages. No server bindings — purely “what file extensions map to what language.” This file is the single source of truth for language detection, replacing the hardcoded tables that existed previously. Users can inspect it to see the exact patterns for every language. -
User config (
~/.config/catenary/config.toml) — full config. Adds server bindings, server definitions, and all other sections ([commands],[notifications],[icons],[tools]). -
Project config (
.catenary.tomlper workspace root) — scoped to[lsp.language.*],[lsp.server.*], and[commands]. Discovered at root addition time. See Project config scope below. -
Explicit file (
CATENARY_CONFIGenv var or--configflag) — full config that overrides the user config. -
Environment variable overrides (
CATENARY_*) — individual field overrides.__maps to TOML nesting (e.g.,CATENARY_ICONS__PRESET=nerd).
Merge rules
Within each layer, Option<T> fields use None-preserving merge:
None (field absent in the overlay) keeps the earlier layer’s value;
Some(v) replaces it. This means a user config that specifies only
servers for a language inherits the default config’s classification
fields (extensions, filenames, shebangs) without repeating them.
For nested structures:
- Scalars replace.
path,args,min_severity,diagnostics. - Tables deep-merge by key. A project
[lsp.server.rust-analyzer]with onlysettingsinheritspathandargsfrom the user’s (or built-in)[lsp.server.rust-analyzer]. - Arrays replace.
servers,file_patterns,extensions,filenames,shebangs, and array-valued settings entries. No concatenation, no deduplication.
Array replacement is deliberate. Array-valued LSP settings are
project-specific (extraPaths, check.targets, cargo.features) —
concatenating a user default with a project override is wrong or
useless. There is no escape hatch for removing a harmful user-level
entry under concatenation. VS Code and Cargo both use the same
convention.
Project config scope
.catenary.toml is restricted to [lsp.language.*], [lsp.server.*], and
[commands]. Other sections are rejected with a warning and guidance
to move them to user config. This is a deliberate narrowing from the
earlier model where project config could contain any section.
Why each remaining section is excluded:
[notifications],[icons],[tools]— these are user preferences, not project-specific. A desktop-notification toggle or icon preset shouldn’t vary per-root.
[commands] in project config
[commands] is the exception to the “user preferences only” rule, but a
narrow one: only build is project-scoped.
build— per-root build tool. The answer to “why can’t I run cargo/npm/go directly?” is inherently per-project. The evaluator resolvescwd(from the hook JSON payload) to a root via longest-prefix match, then looks up that root’s build tool. Disabled roots ([lsp] disable = true) still contributebuild, so a root can name its build tool without spawning servers.
Everything else under [commands] is user-level only: command
enforcement (client_enforcement_only, allow, pipeline, deny,
deny_flags, allow_flags, script_hosts) and denial guidance. A project
.catenary.toml that sets these keys still loads, but they are ignored
with a warning; only build flows through
ResolvedCommands::merge_project_commands. The warning is raised on raw-TOML
presence (ignored_project_command_keys), not the parsed value, so an
explicit = false on a boolean is caught — see below.
This reverses the earlier “project allow replaces the user list, unioned
across roots” model. The command filter resolves daemon-globally:
Session::merged_commands reads the shared LspClientManager’s
daemon-wide roots() + project_commands() with no requesting-session
identity, so every connected session resolves the same set. A project
that changed enforcement would thus change the filter every session sees,
including agents in unrelated repos. This cuts both ways:
- Relaxing (a wider
allow) would weaken the filter for stricter repos sharing the daemon. Fails loud if the project instead wanted less and didn’t get it — the agent hits a hook. - Tightening / turning on
(
client_enforcement_only = falseto request enforcement) would fail silently: enforcement is on/off for the whole daemon, so a project asking for more gets none, and because nothing engages, no agent ever hits a hook to reveal the dropped request. The silent direction is why presence detection (not value) drives the warning —= falseis indistinguishable from absent in the parsed config.
build is exempt: it is consumed per-root via build_for_cwd and only
names a build tool — it relaxes nothing.
The earlier section-scope model (walk up from cwd, merge all sections)
worked for single-project sessions. Multi-root sessions — where catenary pin adds roots with potentially conflicting configs — broke the
assumption. The scope was narrowed to what is genuinely per-root: language
server routing, server configuration, and the per-project build tool.
Per-root settings resolution
When a project .catenary.toml exists, its [lsp.server.*] entries are
deep-merged with user-level server definitions. The merged settings are
stored per-root on LspServer alongside the user-level baseline:
- User-level
settings— the baseline, used when the server asks for configuration without a scope. - Per-root
settings_per_root— from.catenary.tomlper root, deep-merged over the user baseline.
When a language server sends workspace/configuration requests with a
scopeUri, Catenary resolves the root via longest-prefix match against
workspace roots. If the matched root has project-level settings, those
are deep-merged over the user settings and returned. No scopeUri (or
no matching root) returns user settings only.
The interaction with didChangeConfiguration: this notification is
triggered only by catenary pin adding a root with a .catenary.toml.
The server re-sends workspace/configuration requests for its scopes
and gets updated values. Live reload of .catenary.toml is out of
scope — the user restarts the session to pick up project config edits.
Classification
File classification — “what language is this file?” — is config-driven. Three dimensions, checked in precedence order (highest first):
- Shebang — the file’s
#!line declares its interpreter. Matched against theshebangsfield on[lsp.language.*]. - Filename — exact filename match against the
filenamesfield. - Extension — file extension match against the
extensionsfield.
Each tier short-circuits: if a shebang match is found, filename and extension checks are skipped. The merged config (defaults + user + project) is the sole source of classification data — no hardcoded fallback tables exist.
The default config document serves as both reference and fallback. It
defines classification data for every built-in language, and users can
override any of it through the normal merge rules. Setting a
classification field to an empty array clears the default (since arrays
replace). This makes the classification system fully extensible without
code changes — defining a custom language is just adding a
[lsp.language.*] entry with classification fields and a server binding.
Per-root classification tables from project configs override global
tables for files within that root. FilesystemManager resolves the
root for a file path and uses the appropriate classification table.
Tier promotion
Tier promotion is the mechanism for handling conflicting server
configurations across workspace roots. It is triggered by Rule A:
when a project .catenary.toml contains a [lsp.language.X] entry.
Without project config, servers are shared. A workspace-capable server
(one that supports workspaceFolders) gets a single Scope::Workspace
instance serving all roots. Server settings that vary per root are
handled via scopeUri resolution — each root gets its own config when
the server asks for it.
This works for compatible settings. It breaks for server-global settings
that don’t use scopeUri. Concrete example: root A wants
cargo.target = "x86_64", root B wants cargo.target = "aarch64".
A single rust-analyzer process can’t satisfy both, because the
cargo.target setting isn’t per-scope — it applies to the whole
workspace.
The solution: each root adds [lsp.language.rust] to its .catenary.toml.
This triggers Rule A — Catenary spawns a separate Scope::Root
instance for each root, with its own process and its own settings.
# Root A: .catenary.toml
[lsp.language.rust]
servers = ["rust-analyzer"]
[lsp.server.rust-analyzer.settings.rust-analyzer]
cargo.target = "x86_64-unknown-linux-gnu"
# Root B: .catenary.toml
[lsp.language.rust]
servers = ["rust-analyzer"]
[lsp.server.rust-analyzer.settings.rust-analyzer]
cargo.target = "aarch64-unknown-linux-gnu"
The rule is explicit and binding-driven. Users signal “I want an
isolated process for this project” by writing a [lsp.language.*] entry.
The alternative — implicit promotion based on which [lsp.server.*] fields
are present — was rejected because config shape would silently determine
instance topology, making the model hard to reason about.
The resolution matrix:
| Project has | User has | Result |
|---|---|---|
| nothing | [lsp.language.X] + [lsp.server.Y] | User’s tier 2 Y serves this root |
[lsp.server.Y.settings] only | [lsp.language.X] + [lsp.server.Y] | Tier 2 Y serves; project settings scopeUri-merged |
[lsp.language.X] + no [lsp.server.Y] | [lsp.language.X] + [lsp.server.Y] | Tier 1 Y with user’s spawn def; user’s tier 2 Y serves other roots |
[lsp.language.X] + [lsp.server.Y] | [lsp.language.X] + [lsp.server.Y] | Tier 1 Y with project’s spawn def |
No automatic conflict detection or instance splitting. The user makes the call by where they place the config.
Related pages
- Configuration — user-facing reference guide (syntax, examples, full language ID table).
- Routing & Dispatch — how classified files route to server instances and how multi-server dispatch works.
- Session Lifecycle — when config is loaded and how project configs are discovered.
Routing & Dispatch
Every file that enters Catenary — through grep, glob, diagnostics, or any other tool — needs to resolve to one or more language server handles. This page explains how that resolution works: from file path to language, from language to server bindings, from bindings to live server instances.
The motivating case is PKGBUILD files. A PKGBUILD is shellscript, but
it benefits from two servers: termux-language-server for
package-specific hover and diagnostics, and bash-language-server for
shell fundamentals (definitions, references, symbols). The entire
routing system exists to make this kind of multi-server dispatch
correct and predictable.
File classification
Classification — “what language is this file?” — is config-driven. Three dimensions, checked in precedence order (highest first):
- Shebang — the file’s
#!line declares its interpreter. - Filename — exact filename match (e.g.,
PKGBUILD,Makefile). - Extension — file extension match (e.g.,
.rs,.ts).
Each tier short-circuits: if a shebang match is found, filename and extension checks are skipped. The merged config (defaults + user + project) is the sole source of classification data — no hardcoded fallback tables exist. See the Configuration Model page for full detail on how classification tables are built and layered.
For the PKGBUILD example: the file has no extension and no shebang,
but the default config has filenames = ["PKGBUILD"] on
[lsp.language.shellscript]. Filename match → shellscript.
Three-tier routing model
Once a file has a language, Catenary resolves which server instance(s) handle it. The model has three tiers, tried in order for a file at path P:
Tier 1 — Project-scoped
If P’s workspace root has a .catenary.toml with a [lsp.language.X]
entry for P’s language, the instance is bound to that root. Separate
process, isolated config. The instance always uses Scope::Root(root)
regardless of whether the server supports workspaceFolders.
This is Rule A from the configuration model: the presence of
[lsp.language.X] in a project config is the signal for isolation. Users
opt in explicitly by writing the entry. See
Tier promotion for the full
resolution matrix.
Tier 2 — User-scoped
P is inside an active workspace root with no project config override for its language. Two sub-cases based on server capabilities:
-
Workspace-capable servers (those that support
workspaceFolders) share one instance across all roots. The instance usesScope::Workspaceand receivesdidChangeWorkspaceFoldersnotifications as roots are added or removed. -
Legacy servers (no
workspaceFolderssupport) get a separate instance per root, each usingScope::Root(root). They cannot be told about multiple roots, so each process sees only its own.
Tier 3 — Single-file
P is outside all active workspace roots. A server marked
single_file = true in [lsp.server.*] is spawned with a null workspace
(rootUri: null, workspaceFolders: null) to serve the file with a
Scope::SingleFile instance. If the server rejects null-workspace
initialization, the (language, server) pair is negative-cached so it
is not retried. Servers without single_file = true are skipped — a
file outside all roots resolves to nothing for them.
Roots are explicit (pinned), with ephemeral activity mounts
Roots added via --root, catenary pin, or the MCP workspace-roots
channel are pinned: active for the session’s lifetime. Catenary does not
auto-discover pinned roots from file paths — implicit pinned discovery would
make the routing model hard to predict, especially in multi-root sessions where
adjacent directories might contain unrelated projects.
A catenary grep, glob, or diagnostics touching a path outside every
mounted root is the one exception: Catenary detects the enclosing project root
(walking .git up from the path) and mounts it as an ephemeral root so the
query is enriched/diagnosed from a real server. An ephemeral mount is scoped to
the single enclosing root (never a sibling, no companion templating) and
expires after a few minutes of inactivity — every qualifying activity
refreshes its idle clock, and catenary pin on it upgrades it to pinned.
Bare catenary roots and the state.json root board distinguish the two classes.
A file with no detectable enclosing project root still has no owning root; it
routes only to single-file-capable servers (tier 3).
Instance keying
Every live server instance is identified by an InstanceKey — a
three-part identity:
InstanceKey { language_id, server, scope }
All three components are necessary. Without any one of them, collisions occur:
- Without
language_id:clangdserving C and C++ would collapse to one entry. But the two languages may have different dispatch priorities (C++ might have a second server that C doesn’t). - Without
server:termux-language-serverandbash-language-serverfor shellscript would collide. - Without
scope: A project-scoped rust-analyzer for root A and a workspace-scoped rust-analyzer would share a key, but they are distinct processes with distinct configs.
The Scope enum has three variants:
| Variant | Meaning |
|---|---|
Workspace | Shared across roots. One instance per (language, server) pair. |
Root(PathBuf) | Bound to a specific root. Used for legacy servers and project-scoped instances. |
SingleFile | Tier 3. A single_file = true server spawned with a null workspace for a file outside all roots. |
Instance lookup
find_instance resolves a (language, server, root) triple to a live
client by trying Scope::Root(root) first, then Scope::Workspace.
Root-first ordering is essential: when a project-scoped instance and a
workspace instance both exist for the same language and server, the
project-scoped instance must win for files in its root. Without this
ordering, project-scoped isolation would be silently bypassed.
Dispatch model
Once routing resolves the candidate servers, dispatch determines how results are collected. There are two separate paths, because request/response methods and diagnostics have fundamentally different semantics.
Request/response — priority chain
For methods like textDocument/definition and
textDocument/references, the servers list order in [lsp.language.*]
defines priority. Dispatch iterates servers in that order:
- Check capability — does this server support the method?
- Send request.
- If the response is non-empty, return it. Done.
- If the response is empty or null, try the next server.
First non-empty result wins. No merging across servers.
Merging was rejected for two reasons. First, less-specific servers
produce noise: bash-language-server returns shell-level hover for a
PKGBUILD symbol that termux-language-server already explains with
package-specific context. Merging would show both, with no way to
signal which is authoritative. Second, non-list methods (hover,
definition) have ambiguous merge semantics — two hover results for the
same position can’t be meaningfully combined.
For the PKGBUILD case: termux-language-server is listed first in
servers, so it gets first shot at every request. If it returns
nothing for a particular symbol (say, a shell builtin it doesn’t
know about), bash-language-server handles it as fallback.
Diagnostics — concatenation
Diagnostics are server-pushed, not request/response. Every server
with diagnostics enabled for the file’s language binding receives
didOpen and produces diagnostics independently. Results are
concatenated — all servers contribute.
This is the right model because diagnostic domains are typically
non-overlapping. termux-language-server reports package validation
issues (missing dependencies, invalid fields). bash-language-server
reports shell syntax issues (unquoted variables, missing semicolons).
Both are useful; neither subsumes the other.
Opt-out is available at two levels:
- Per-binding:
{ name = "bash-language-server", diagnostics = false }in theserverslist suppresses diagnostics from that server for that language. - Language-level:
diagnostics = falseon[lsp.language.shellscript]suppresses diagnostics from all servers for that language.
The effective filter is AND: both the language-level flag and the
per-binding flag must be true for diagnostics to be delivered from a
given server. This means language-level false is a wholesale kill
switch that overrides any per-binding setting.
file_patterns filtering
file_patterns on [lsp.server.*] is a dispatch-layer narrowing
mechanism. It contains filename-level globs (matched against the
filename component, not the full path) that limit which files within
a language the server handles.
Servers without file_patterns handle all files for their language.
Servers with it only handle files whose name matches at least one
pattern.
file_patterns is applied inside get_servers before the capability
check. This means a server with non-matching file_patterns is never
considered — not for requests, not for diagnostics, not for document
lifecycle.
For the PKGBUILD case: termux-language-server has
file_patterns = ["PKGBUILD", "*.ebuild"]. When a file named
install.sh enters as shellscript, termux is filtered out by
file_patterns and only bash-language-server handles it. When
PKGBUILD enters, both servers pass the filter.
The PKGBUILD walk-through
Putting it all together — a textDocument/references request for a
symbol in a file named PKGBUILD:
-
Classification.
PKGBUILDhas no extension. No shebang. Filename match against[lsp.language.shellscript]filenames → language isshellscript. -
Root resolution.
FilesystemManager::resolve_rootfinds the owning workspace root via longest-prefix match. -
Language config lookup.
[lsp.language.shellscript]has:servers = ["termux-language-server", "bash-language-server"] -
file_patternsfilter.termux-language-serverhasfile_patterns = ["PKGBUILD", "*.ebuild"]—PKGBUILDmatches.bash-language-serverhas nofile_patterns— passes by default. -
Instance lookup. For each server,
find_instancechecksScope::Root(root)thenScope::Workspace. Returns the live client for each. -
Capability check. Both servers support
textDocument/references. Both pass. -
Priority chain dispatch.
termux-language-serveris first in the list. SendtextDocument/references. If it returns results, done. If empty, fall through tobash-language-server. -
Diagnostics (separate path). Both servers have the file open (assuming diagnostics are enabled for both bindings).
termux-language-serverreports package validation issues.bash-language-serverreports shell syntax issues. Both sets are concatenated in the diagnostic result.
Dispatch errors
LSP-side errors during dispatch never reach the agent. All errors are
routed through warn!() via tracing, which LoggingServer surfaces as a
health finding on the TUI dashboard and records in the firehose. The agent
sees empty results or whatever partial results were available from other
servers in the chain.
This separation is deliberate: the agent cannot act on “rust-analyzer returned error code -32602.” The user can — they can check their config, restart the server, or file a bug. See the Logging, Hooks & TUI page for the three-audience model (agent, user real-time, user investigating).
Caller-input errors are different. Invalid regex patterns, bad file paths, and other agent-supplied mistakes do surface in the tool result, because the agent can fix them by adjusting its input.
The get_servers interface
#![allow(unused)]
fn main() {
pub async fn get_servers(
&self,
path: &Path,
capability: fn(&LspServer) -> bool,
method: Option<DispatchMethod>,
) -> Vec<Arc<Mutex<LspClient>>>
}
This is the routing entry point. Every tool server calls it to resolve a file path to an ordered list of server handles. The function:
- Classifies the file to a language ID.
- Resolves the owning workspace root — or, for a file outside all roots, falls through to the single-file tier.
- Looks up the language config for server bindings.
- Iterates bindings in priority order, filtering by per-binding
disabled_methods(when amethodis given),file_patterns, instance liveness, and the capability predicate. - Returns clients in binding order (priority order).
get_servers is non-blocking — it reads current capability state
without waiting for servers to finish initializing. Callers are
responsible for readiness via wait_ready_for_path or
wait_ready_all before invoking.
An empty result triggers a warn!() (unless the language has no
configured servers) — surfaced as a health finding on the TUI dashboard and
recorded in the firehose. The health surface is state-based, so the same “no
server supports X” condition shows as one finding, not a stream, across
repeated tool calls.
A separate diagnostic_servers method wraps get_servers with the
diagnostics capability check and the additional config-level
diagnostics_enabled filter (language-level AND per-binding). This
is the entry point for DiagnosticsServer.
Related pages
- Configuration Model — config sources, layering, classification, tier promotion.
- Session Lifecycle — when servers are spawned and how roots are discovered.
- LSP Client Layer — connection management, capabilities, settle/idle detection.
- Logging, Hooks & TUI — dispatch error routing and the three-audience model.
LSP Client Layer
The LSP client layer is the most complex subsystem in Catenary. It manages server processes, protocol state, capability negotiation, idle detection, and readiness signaling across potentially many concurrent language server instances. This page explains the internal structure and the reasoning behind it.
Three-layer architecture
The waitv2 rewrite established three layers with distinct responsibilities:
┌─────────────────────────────────────────────────────┐
│ LspClient │
│ High-level operations: hover, references, definition│
│ Document open/close state (per-client versioning) │
│ Readiness waiting (wait_ready) │
│ Health probing │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ Arc<LspServer> │ │
│ │ Capabilities (OnceLock<bool> per method) │ │
│ │ InstanceKey (language, server, scope) │ │
│ │ Diagnostics cache + generation counters │ │
│ │ Progress tracking ($/progress) │ │
│ │ Lifecycle state machine (ServerLifecycle) │ │
│ │ Per-root settings + scopeUri resolution │ │
│ │ File watcher registrations │ │
│ │ Notification dispatch (on_notification) │ │
│ │ Server request dispatch (on_request) │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ Connection │ │ │
│ │ │ Child process (stdin/stdout) │ │ │
│ │ │ Reader loop (background tokio task) │ │ │
│ │ │ Request/response correlation │ │ │
│ │ │ JSON-RPC framing (Content-Length) │ │ │
│ │ │ CPU-tick failure detection │ │ │
│ │ │ Retry on ContentModified (-32801) │ │ │
│ │ │ MCP cancellation → $/cancelRequest │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Connection — raw JSON-RPC transport
Connection owns the child process and the reader loop. It sends and
receives raw serde_json::Value messages over Content-Length-framed
stdio. It knows about JSON-RPC request/response correlation (pending
request map with oneshot channels) but nothing about LSP semantics.
Key behaviors:
-
Reader loop. A background tokio task reads stdout, parses Content-Length frames, and routes messages. Responses are matched to pending requests by ID and delivered through
oneshotchannels. Notifications and server-initiated requests are forwarded toLspServerviaon_notificationandon_request. -
Failure detection.
Connection::requestdoes not use a simple wall-clock timeout. Instead, it polls aProcessMonitor(from thecatenary_proccrate) every 200ms, tracking CPU ticks consumed by the server process. If the server burns 10 CPU-seconds without responding and is not reporting progress, the request is considered stuck. A 30-second wall-clock deadline exists as a fallback when process monitoring is unavailable. -
Retry.
ContentModified(-32801) andRequestCancelled(-32800) errors trigger automatic retry (up to 3 attempts), with a wait for server state change between retries. -
MCP cancellation. When the MCP client cancels a tool call, the
CancellationTokenfires.Connection::requestsends$/cancelRequestto the LSP server and returns aRequestCancellederror, which propagates back through the MCP response. -
Process lifecycle.
Connection::newspawns the process withset_parent_death_signal(so the server dies if Catenary dies) and registers it for cleanup.Dropkills the child to prevent zombies.
LspServer — protocol state
LspServer is the knowledge layer. It knows what the server can do
and how it is configured, but does not own I/O. It is created at spawn
time (before initialize) with empty OnceLock fields that are
populated once after the init handshake.
Shared via Arc<LspServer> between LspClient and Connection. The
reader loop holds a Weak<LspServer> so it can forward notifications
without preventing cleanup.
Key responsibilities:
-
Static capabilities.
OnceLock<bool>fields for each supported method:supports_hover,supports_definition,supports_references,supports_document_symbols, etc. Set once byset_capabilitiesfrom theInitializeResult. Returnfalsebefore initialization completes — conservative by default. -
Dynamic capabilities.
supports_pull_diagnosticsusesAtomicBoolinstead ofOnceLockbecause it can downgrade at runtime. IftextDocument/diagnosticfails repeatedly on a server that advertiseddiagnosticProvider, the capability is permanently disabled viadowngrade_pull_diagnosticsand the server falls back to push diagnostics. -
Dynamic registration. Handles
client/registerCapabilityandclient/unregisterCapabilityfor two registration types:workspace/didChangeWatchedFiles(file watcher patterns stored per registration ID) andworkspace/didChangeConfiguration(tracked as a registration ID set). -
Notification dispatch.
on_notificationroutes incoming server notifications:textDocument/publishDiagnostics— diagnostics cache + generation counter + waiter notification.$/progress— progress tracker + lifecycle state transitions (Healthy/Busy(n)based on begin/end counts).window/logMessage,window/showMessage— debug logging.
-
Server request dispatch.
on_requesthandles server-initiated requests:workspace/configuration— resolves settings withscopeUriawareness (per-root project config overlays, deep merge).client/registerCapability/client/unregisterCapability— file watcher and configuration registration management.window/workDoneProgress/create— acknowledged (no-op).
-
Identity. Language ID, server name (both known at spawn time, immutable), and scope (
OnceLock, set once after init). Together these form theInstanceKey. -
Configuration. User-level
settings(immutable after construction) andsettings_per_root(Mutex<HashMap>, updated mid-session when roots are added).resolve_configurationdoes longest-prefix match forscopeUriresolution, deep-merging project overlays over user defaults.
LspClient — high-level operations
LspClient owns an Arc<LspServer> and accesses the Connection
through it (server.request() delegates to connection().request()).
It provides typed methods that compose connection sends with server
state:
#![allow(unused)]
fn main() {
pub async fn hover(&self, uri: &str, line: u32, character: u32) -> Result<Value>
pub async fn references(&self, uri: &str, ...) -> Result<Value>
pub async fn definition(&self, uri: &str, ...) -> Result<Value>
pub async fn document_symbols(&self, uri: &str) -> Result<Value>
// ... and many more
}
Each method calls require_capability first (checking the relevant
LspServer flag), then delegates to Connection::request with the
current parent_id for causation tracking.
Client-local state (not shared with the reader loop):
-
Document tracking.
open_documents: HashMap<String, i32>— per- client URI to version map. Each client maintains independent monotonic version sequences, so multi-server dispatch gives each server a clean sequence starting at 1.open_documentreturns(first_open, version): first open sendsdidOpen, subsequent opens senddidChange. -
Position encoding. Negotiated during
initialize(defaults to UTF-16 per spec). -
Causation tracking.
parent_idlinks all LSP messages to their originating CLI command for database correlation and TUI display. -
Cancellation.
cancel: CancellationToken— set before each tool dispatch, propagated toConnection::request.
Why three layers
The separation is not arbitrary. Three concrete benefits:
-
Connectionis testable without LSP knowledge. It deals in raw JSON values and framing. The reader loop can be tested with any JSON-RPC messages, not just LSP. -
LspServeris inspectable without I/O.get_serversreads capability flags and lifecycle state on theArc<LspServer>without acquiring theLspClientmutex. The routing layer can filter servers by capability without blocking on in-flight requests. -
LspClientcomposes both. Typed methods enforce capability checks before sending requests, and theparent_id/ cancel token flow through naturally from the MCP layer.
Two-step spawn lifecycle
InstanceKey cannot be constructed before initialize — the scope
depends on whether the server supports workspaceFolders, which is
only known from the InitializeResult. Spawning therefore splits
into two steps:
-
spawn_inner(onLspClientManager) creates theLspServer, spawns theConnection(child process + reader loop), constructsLspClient, and runsinitializewith the workspace roots. -
Scope determination. From the
initializeresponse:- If project-scoped (Rule A — root has
[lsp.language.*]in.catenary.toml): scope is forced toScope::Root(root)regardless of capabilities. - If workspace-capable (
workspaceFolderssupported): scope isScope::Workspace. - Otherwise: scope is
Scope::Root(root)(legacy per-root).
set_scopeis called onLspServer, and the fullInstanceKeyis constructed and inserted into the client map.Files outside all workspace roots take a separate path:
spawn_single_filesetsScope::SingleFilebeforeinitialize(the scope is known without the handshake) and initializes with a null workspace. It is gated onsingle_file = truein[lsp.server.*], and a server that rejects null-workspace init is negative-cached. See the single-file tier in Routing & Dispatch. - If project-scoped (Rule A — root has
The clients lock is held across the entire sequence (spawn_inner
acquires it before the double-spawn check and holds it through
insertion). This prevents races where two concurrent spawns for the
same language/server/root both succeed and insert — only the first
one wins.
spawn_inner:
lock clients
├── double-spawn check (existing alive instance?)
├── LspServer::new()
├── Connection::new() → child process + reader loop
├── LspClient { server, connection }
├── client.initialize(roots)
├── determine scope from capabilities
├── server.set_scope(scope)
├── construct InstanceKey
├── clients.insert(key, client)
└── unlock
Server lifecycle
ServerLifecycle is a single enum that tracks the server from spawn
through shutdown:
| State | Meaning |
|---|---|
Initializing | Spawned, init handshake not yet complete. |
Probing | Init complete, server unproven. Tool requests proceed as self-tests. |
Healthy | Proven working, idle, accepts all requests. |
Busy(n) | Server declared active via $/progress begin. n = in-flight token count. |
Failed | Health probe failed or init error. Terminal. |
Dead | Connection lost / process died. Terminal. |
State transitions:
Initializing ──► Probing ──► Healthy ◄──► Busy(n)
│ │
▼ ▼
Failed Dead
Probing is the key innovation. After initialize, the server is
unproven — it may crash on real requests. Rather than running a
separate health check that blocks all tool calls, Probing allows tool
requests to proceed as self-tests. The first successful response
transitions Probing to Healthy (via
try_transition_probing_to_healthy in the LspClient::request
wrapper). If the server fails, the diagnostics path runs an explicit
health probe (run_health_probe sends textDocument/documentSymbol)
that transitions to either Healthy or Failed.
Busy carries a count. Multiple concurrent $/progress begin
tokens increment the count; each end decrements it. When the count
reaches zero, the server returns to Healthy.
A terminal-state transition emits a warn!() through LoggingServer: it
surfaces as a health finding on the TUI dashboard (a routed-but-broken server —
“Language server unavailable: rust (rust-analyzer)”) and lands in the firehose,
without the agent having to report it.
Capability model
Static capabilities
Populated once from InitializeResult via set_capabilities. Each
capability is an OnceLock<bool> on LspServer:
InitializeResult.capabilities:
hoverProvider → supports_hover
definitionProvider → supports_definition
referencesProvider → supports_references
documentSymbolProvider → supports_document_symbols
workspaceSymbolProvider → supports_workspace_symbols
renameProvider → supports_rename
typeDefinitionProvider → supports_type_definition
implementationProvider → supports_implementation
callHierarchyProvider → supports_call_hierarchy
typeHierarchyProvider → supports_type_hierarchy
codeActionProvider → supports_code_action
diagnosticProvider → supports_pull_diagnostics (AtomicBool)
textDocumentSync → supports_text_document_sync
The extraction uses a simple rule: true or a non-null options object
means supported; false, null, or absent means not. Before
set_capabilities is called, all flags return false —
conservatively correct, since get_servers filters by capability and
an uninitialized server should not be selected.
Dynamic downgrade
supports_pull_diagnostics is the only capability that can change
after initialization. When textDocument/diagnostic fails on a server
that claimed diagnosticProvider, downgrade_pull_diagnostics flips
the AtomicBool to false. Subsequent get_servers calls with the
diagnostics capability check naturally exclude this server from pull
diagnostics. The server continues to produce push diagnostics via
publishDiagnostics notifications (if it supports
textDocumentSync).
Idle detection and settle
After sending a stimulus to a language server (e.g., didOpen for the
diagnostics pipeline), Catenary must wait for the server to finish
processing before reading results. This is the settle model.
IdleDetector
IdleDetector is a pure state machine. Given a process tree snapshot
(from catenary_proc::TreeMonitor), it determines whether the server
is idle. Two modes:
-
after_activity(post-stimulus) — requires observing activity before accepting silence as idle. Two phases:- Wait for cumulative CPU ticks to advance from a pre-stimulus baseline, or any nonzero per-process delta. Either proves the server was scheduled.
- Wait for all processes to show zero deltas with per-child gates (every process that appeared during processing must show activity at least once before its silence counts as idle).
-
unconditional(pre-stimulus) — accepts silence immediately. Used to verify the server is quiet before sending a stimulus.
Per-child gates
When a new process appears in the tree during processing (e.g.,
cargo check spawns rustc), it gets a gate that blocks idle
detection until it shows at least one nonzero delta. This prevents
false idle: a child process that was just spawned but hasn’t been
scheduled yet appears quiet, but silence means nothing because it
hasn’t had a chance to run. Dead processes bypass the gate — a zombie
that never showed activity is not evidence of pending work.
await_idle
The production function wraps IdleDetector in a polling loop:
- Polls every 50ms via
spawn_blocking(process tree reads are sync/procfilesystem operations). - Tracks cumulative CPU time against a 60-second budget (6000 centiseconds). If the server consumes 60 CPU-seconds without settling, the budget is exhausted and the caller proceeds with whatever diagnostics are available.
- Pauses during
Busy(n)lifecycle state — progress tokens are explicit activity declarations, so tree walking is unnecessary. - Detects root process death (empty snapshot, zombie root, missing
PID) and transitions to
Dead. - Respects a
CancellationTokenfor MCP-level cancellation.
Returns one of three outcomes: Settled (server is idle),
BudgetExhausted (timeout), or RootDied (process gone).
Wait primitives
LspClientManager provides three wait primitives that compose
LspClient::wait_ready:
| Primitive | Behavior |
|---|---|
wait_ready_for_path(path) | Waits for every server bound to the path’s language. |
wait_ready_all() | Waits for every active instance across all languages. |
ensure_and_wait_for_paths(paths) | Spawns missing servers for discovered languages, then waits for all. |
wait_ready on LspClient watches the lifecycle enum — it wakes on
every lifecycle transition and returns true for Healthy or
Probing (both accept requests), false for Failed or Dead. No
budget, no tick counting, no process sampling at this level. Servers
that pass health are waited for patiently; Connection::request
handles individual stuck requests with its own failure detection.
The typical tool call sequence is: wait_ready_for_path then
filesystem change notifications then get_servers then dispatch. By
the time get_servers runs, capability state is populated and the
capability filter is reliable.
LspClientManager
LspClientManager is the lifecycle authority. It owns the client map
(HashMap<InstanceKey, Arc<Mutex<LspClient>>>), spawns and shuts down
instances, manages document state, and provides the routing interface.
Key operations:
spawn_all— initial startup. Walks workspace roots, classifies files, detects languages, spawns servers. Handles workspace folder exclusion for project-scoped roots.ensure_server— lazy spawn for a single language/server/root. Checks for project-scope first; delegates tospawn_inner.sync_roots— mid-session root changes. Diffs roots, notifies workspace-capable servers viadidChangeWorkspaceFolders, spawns / shuts down per-root instances, loads project configs.get_servers— the routing entry point. See Routing & Dispatch.shutdown_all— sendsshutdown+exitto every live instance.
Related pages
- Routing & Dispatch — how files resolve to server
handles via
get_servers. - Session Lifecycle — when
spawn_allruns and how roots are managed. - Configuration Model — server definitions, language bindings, project config.
- Document Lifecycle & File Watching — document sync
pipeline and
didChangeWatchedFiles.
Document Lifecycle & File Watching
When an agent calls a Catenary tool that touches a file — grep needs
hover information, glob needs a symbol outline, diagnostics need error
lists — LSP requires that file to be explicitly “opened” on the
server before any request can be sent. Catenary manages this lifecycle
entirely: the agent never sends didOpen or didClose directly.
This page covers two interconnected subsystems: document lifecycle (how files move through open/close states on language servers) and file watching (how servers learn about filesystem changes that happen outside the document sync pipeline).
Two open paths
Document opens follow the same split as dispatch: request/response methods and diagnostics have different needs, so they have different open paths.
open_document_on — targeted open
Used by request/response dispatch. The caller gets an ordered list
of clients from get_servers
and opens the file on each as it iterates the priority chain:
tool call → get_servers(path, capability) → [client_a, client_b, ...]
for each client:
open_document_on(path, client) → didOpen or didChange
send request
if non-empty result: return (done)
The caller controls which servers see the file. A server that
fails the capability check or file_patterns filter in get_servers
never gets an open.
diagnostic_servers — broadcast open
Used by the catenary diagnostics pipeline. diagnostic_servers on
LspClientManager returns every server where diagnostics_enabled
is true for the file’s language binding. It applies both the
capability gate (supports_diagnostics) and the config-level filter
(language-level AND per-binding diagnostics flags). Every qualifying
server receives the file via open_document_on and produces
diagnostics independently.
Per-client version tracking
Each server gets an independent monotonic version sequence. The first
open_document call for a URI returns (first_open: true, version: 1)
and sends textDocument/didOpen. Subsequent calls increment the
version and return (false, version) — the caller sends
textDocument/didChange with the full file content.
This per-client tracking means multi-server dispatch gives each server a clean sequence starting at 1, regardless of how many other servers have the same file open. LSP requires monotonically increasing versions per server — sharing a global counter across servers would create gaps that some servers reject.
Stateless document lifecycle
Outside editing mode, Catenary uses a stateless document lifecycle: open → request → close per tool call. No document state accumulates across calls. After each tool dispatch, any file that was opened for that request is closed.
This is a deliberate design choice from the waitv2 rewrite. Stateless
lifecycle eliminates migration concerns when routing changes mid-session
— for example, when catenary pin shifts which server handles a file, or
when a project-scoped server is spawned that shadows a workspace
instance. There is no accumulated document state to reconcile when
ownership changes.
The cost is that every tool call re-reads the file and sends the full
content. In practice this is cheap: files are already in the OS page
cache from the agent’s own reads, and the didOpen/didClose
round-trip is a pair of notifications (no server response to wait for).
Editing mode
Editing mode is Catenary’s primary user-facing innovation for diagnostic batching. It exists to solve a specific problem: AI agents make many rapid edits, and per-edit diagnostics are noisy, slow, and often stale by the time they arrive.
The problem
Without editing mode, each file edit triggers a diagnostic cycle:
open the file on all diagnostic-enabled servers, wait for each to
settle, collect diagnostics, return them to the agent. For a typical
refactoring that touches 10 files, that is 10 separate diagnostic
cycles — each with its own settle wait. Worse, intermediate
diagnostics are misleading: renaming a type in lib.rs produces
errors in every file that imports it, but those errors will be fixed
by the next edit.
The solution
Editing mode brackets a batch of file edits. It starts implicitly on
the first edit to a server-covered file — there is no separate editing start step to race against parallel tool calls — and ends when the agent
runs catenary diagnostics. During editing mode:
- No LSP traffic for intermediate edits. The agent edits freely
with the host CLI’s native Edit/Write tools (or native
sed -i, whose writes the hook resolves and tracks). NodidOpen, nodidChange, no diagnostic retrieval per edit. - Path accumulation. The
PreToolUsehook detects edit-tool calls and accumulates the modified file paths inEditingManager. Paths are deduplicated — editing the same file twice records it once. A path is tracked only if a configured server would cover it (coverage-gated): doc-only or no-server edits accumulate nothing and never enter editing mode, so no diagnostics would be produced for them. - Boundary enforcement. While a non-empty covered tracked set
is pending, the
PreToolUsehook blocks tool calls that would leave the edit batch. Read/Write,ToolSearch, filesystem-only Bash (rm,cp,mv), and canonical Catenary commands (grep/glob, the lifecycle commands) stay allowed; everything else is denied with a message framed as a helpful next step, not a fault: it lists the edited-but-not-yet-diagnosed files grouped under each diagnostic feeder (LSP server / linter) tracking them, then teaches the two ways to clear them — barecatenary diagnostics(all) andcatenary diagnostics <those files>(scoped, shown with the agent’s real outstanding paths) — and names the blocked command to re-run once the debt is paid. The block gates on the tracked set, not an editing-mode bit — an empty set flows free, so friction tracks value. - Batched diagnostics. When the agent runs
catenary diagnostics, theDiagnosticsServerruns a single consolidated diagnostic pipeline across all modified files. Naming paths (catenary diagnostics <paths>) scopes the pipeline to exactly those files and pays only their share of the gate — a partial pull leaves the tracked set (and the boundary block) armed for the files not yet diagnosed.
The catenary diagnostics pipeline
catenary diagnostics triggers a multi-phase pipeline on
DiagnosticsServer:
-
File change notifications. The diagnostics stat-walk doubles as change detection: changed files are diffed against each root’s mtime baseline (
diff_and_update) and delivered to servers as a per-server changed-set (nudge_changed_set) first, so servers know about any new or deleted files before the diagnostic cycle. -
Resolve and group. Modified files are canonicalized, validated against workspace roots, and grouped by diagnostic-enabled server. Files outside workspace roots or with no server coverage are categorized as N/A.
-
Per-server batch lifecycle. For each server, the pipeline runs:
- Open all files —
open_document_onfor every file in the server’s group. The server sees the complete final state of all files simultaneously. - Settle — wait for the server to finish processing via the idle detection model.
- Health probe — if the server is still in
Probingstate, run an explicit health check. didSaveall — triggers flycheck on servers that only produce diagnostics on save (e.g., rust-analyzer runscargo checkondidSave).- Settle again — wait for flycheck to complete.
- Retrieve diagnostics — read per-file diagnostics from the server’s cache.
- Close all —
didClosefor every opened file.
- Open all files —
-
Format output. Results are categorized: files with diagnostics get per-line error/warning output, clean files are grouped on one line, N/A files are grouped separately.
-
Baseline already current. No separate cache-refresh pass is needed: the change-detection walk in step 1 already recorded these files’ mtimes in each root’s baseline, so the next walk will not re-report them as changed (see interaction with editing mode below).
Cross-file diagnostics are correct because each server sees the
complete final state before producing diagnostics. A renamed type
in lib.rs and its updated imports in main.rs are both open on
the server simultaneously — the server produces diagnostics that
reflect the fully consistent state.
State ownership
EditingManager holds the in-memory editing state: a map from
agent_id to accumulated file paths. Both the HookRouter (which
has the real agent_id from the host CLI) and the IPC router (which
handles CLI commands) access it through Session.
Editing state transitions are owned by the PreToolUse hook (because it
has the agent_id): the first covered edit implicitly enters editing
mode and accumulates the path. catenary diagnostics is a CLI command
invoked via the host’s shell tool; the IPC router handles its diagnostic
pipeline (because it produces the stdout output) and clears the tracked
set. SessionStart clears any stale editing state from a previous agent
context. (catenary editing start survives only as an idempotent no-op
for a stray invocation — it is not part of the agent-facing workflow.)
File watching
File watching is separate from document lifecycle.
workspace/didChangeWatchedFiles notifies servers about filesystem
changes that happen outside the document sync pipeline — new files
created by the agent, files deleted, files modified by Bash commands
or external tools.
Why not a traditional file watcher
Most LSP clients use a background file watcher (inotify on Linux,
FSEvents on macOS) that fires events continuously over the whole
workspace. Catenary doesn’t — it detects changes by walking at query
time, the same walk grep/glob/diagnostics already perform:
- Zero idle overhead. No background watcher over workspace files, no recursive inotify watches, no file-descriptor budget to manage. Between tool calls, Catenary does nothing.
- No sub-
O(tree)consumer to accelerate.grepisO(tree)and asks a fresh question each time, so an incremental change feed would not speed it up. Walking on demand yields change detection for free, off work the query already does. - No watch limits. Large monorepos can exceed the default inotify
watch limit; Catenary never hits it because it registers no recursive
workspace watches. Directory walking uses the
ignorecrate (already aFilesystemManagerdependency).
The tradeoff: changes are not detected instantly. They are detected at the next tool boundary, which is when they matter — that is when the agent is about to interact with servers.
(Catenary does use the notify crate for one narrow, unrelated purpose:
a bounded, non-recursive directory-deletion watch on subagent worktree
roots, for teardown — not workspace-file watching. See Logging, Hooks &
TUI.)
Walk-on-demand change detection
FilesystemManager detects changes by walking on demand and diffing
against a per-root mtime baseline:
-
First walk is the baseline. There is no separate seed step. The first walk of a root records
(path, mtime)for every file (stat-only, no content read;.gitignorerespected via theignorecrate) and establishes that root’s baseline. -
diff_and_updateon each walk. A later walk compares the current(path, mtime)against the baseline, produces a change set —Created,Changed, orDeleted— then merges the new mtimes back into the baseline. (The first walk’s whole observation is reported asChanged— the cold snapshot.) -
Per-server routing. Each covering server’s registered watchers are snapshotted; observations are filtered to the union of their globs, then fanned out so each server receives only the changes that match its own globs and watch-kind mask (create / change / delete). Servers register interest via
client/registerCapabilityforworkspace/didChangeWatchedFiles. -
Precise notification. Each server gets a single
workspace/didChangeWatchedFilescarrying only its matching changes, as(uri, changeType)pairs whosechangeTypeis the true semantic kind (Created → 1, Changed → 2, Deleted → 3). It is never a broad unconditional poke.
Registration management
Glob registrations live on LspServer. They are populated via
client/registerCapability (the server declares what file patterns it
wants to watch) and cleared via client/unregisterCapability.
Registration IDs are tracked so specific registrations can be removed
without affecting others. Each watcher compiles its glob with LSP 3.17
semantics and carries a watch-kind mask; the matcher gates on both.
The registrations are snapshotted before matching, so the registration lock is not held during the (potentially slow) glob matching and notification loop.
Interaction with editing mode
The change-detection walk at the start of catenary diagnostics also
updates each root’s mtime baseline, recording the edited files at their
final mtime. A later walk therefore does not re-report them as
Changed, and servers do not receive redundant didChangeWatchedFiles
events for content they have already seen (via didOpen/didChange
during the diagnostic pipeline). Deletions are folded into the baseline
the same way, so they are not reported twice.
When notifications fire
Change detection rides each query’s walk, so a notification can fire on:
grep— the search walk collects mtimes and feeds the changed-set before results are returned.glob— the scoped directory scan (nudge_scoped) does the same for the listed paths.catenary diagnostics— the stat-walk runs first, so servers know about any creates or deletes before receivingdidOpenfor modified files.
The notification is a no-op when the walk finds no changes — the common case when the agent has not touched the filesystem since the last query.
Related pages
- Routing & Dispatch — how files resolve to server handles, priority chain vs. diagnostic concatenation.
- LSP Client Layer — connection management, capabilities, settle/idle detection used by the diagnostic pipeline.
- Session Lifecycle — daemon startup and the serving loop, including change detection on each query walk.
- Configuration Model —
diagnosticsflags on language bindings and servers. - Logging, Hooks & TUI — hook integration for editing mode enforcement.
Logging, Hooks & TUI
Catenary produces a large volume of protocol traffic — hundreds of LSP messages per tool call, MCP request/response pairs, hook invocations. This page explains how that traffic is captured, how it reaches the right audience, and how the TUI makes it human-readable.
Three-audience surface model
Every piece of telemetry in Catenary is destined for one of three audiences. This separation is the organizing principle for the entire observability stack.
| Audience | Channel | What goes here |
|---|---|---|
| Agent | Tool result content | Data + caller-input errors the agent can fix |
| User (urgent) | Desktop notification | Error-severity events — the interrupt |
| User (dashboard) | TUI health surface | Warns + errors as durable findings |
| User (investigating) | Firehose (catenary query) | Full audit trail |
Why the separation matters: agent context is expensive (tokens), and Catenary’s internal problems — server crashes, config errors, routing failures — are not the agent’s problem. Surfacing them in tool results wastes context on information the agent cannot act on. User-facing channels exist for everything else.
The agent sees only data it asked for (hover results, diagnostics,
grep matches) and errors it can fix (file not found, ambiguous path).
The user sees the urgent interrupt (errors) as a desktop notification, and
everything user-actionable — warns included — as a durable finding on the TUI
health dashboard. The full protocol trace is always available through the
firehose for debugging. The former store-and-forward systemMessage
notification queue retired in the TUI rework (it delivered stale truths; a
state-based health surface cannot).
LoggingServer — the sole telemetry port
LoggingServer is a tracing_subscriber::Layer. Every
tracing::info!(), warn!(), error!() call in the codebase flows
through it. It is Catenary’s only telemetry port — there is no separate
error reporting path, no separate protocol logging path, no side channel
for notifications. Everything goes through tracing, and LoggingServer
dispatches to sinks.
Two-phase construction
LoggingServer starts in buffering mode. During early startup (config
loading, daemon assembly), tracing events are captured in a bounded
in-memory buffer (4096 events). Nothing is written to disk yet — the
sinks do not exist.
When daemon assembly constructs the sinks,
LoggingServer::activate(sinks) is called. This drains the bootstrap
buffer through the sinks in FIFO order and switches to direct dispatch.
From this point, every tracing event flows to the sinks immediately.
Activation depends only on sink readiness — there is no database
connection or migration.
If bootstrap events were dropped due to buffer overflow, activate emits
a warn!() describing the loss. That event flows through the now-active
sinks like any other.
Sinks
Post-activation, the sinks receive every tracing event:
-
Desktop notification sink — fires an OS-level notification for error-severity events only, the urgent interrupt. Deduped per daemon lifetime; suppressed by
[notifications] desktop = falseorCATENARY_NOTIFY=0. See notification channels below. -
JSONL firehose — appends every event as one JSON line to a sharded, append-only log under
cache_dir(see Data source below). Protocol events carrykindin{lsp, mcp, hook}; internal events usekind = "internal". Thelevelfield always reflects the event’s tracing severity. This is the full audit trail, read after the fact bycatenary query. -
Snapshot alert ring —
warn!()/error!()events are also folded into the alert ring of the daemon-ownedstate.jsonsnapshot, so the TUI surfaces them without reading the firehose.
The firehose sink replaces the former MessageLog (protocol logging),
ErrorLayer (error reporting), and the SQLite messages table — all
removed during the logging consolidation and the observability rewrite.
The consolidation means every telemetry event follows the same pipeline
regardless of origin.
Hot path
Post-activation, the dispatch hot path is lock-free: a single
OnceLock::get (atomic load) reads the sinks slice and dispatches
directly. No Mutex, no Vec clone, no refcount bumps per event. Each
sink call is wrapped in catch_unwind — a panicking sink does not
prevent other sinks from receiving the event or crash the caller.
Correlation IDs
LoggingServer::next_id() mints monotonic in-process correlation IDs
(AtomicI64, session-scoped, starts at 0). Protocol boundary components
use these for two purposes:
request_id— pairs a request with its response. The TUI joins on this field to create merged display entries with timing.parent_id— links LSP messages to the CLI command that caused them. The TUI uses this for scope collapse — hundreds of LSP messages from a single grep call group behind one summary line.
IDs are in-process monotonic values, not database ROWIDs. This avoids round-trip latency and lets correlation work even before the database write completes.
Notification channels
Catenary tells the user about operational problems without spending agent context. Severity picks the channel:
error!()fires a desktop notification (the urgent interrupt), lands as a TUI health finding, and is recorded in the firehose.warn!()lands as a TUI health finding (no interrupt) and is recorded in the firehose. A warn is a health finding — stale hooks, version skew, coverage degradation.info!()/debug!()go to the firehose only.
The desktop sink dedups per daemon lifetime, so a repeated error fires once. Findings persist on the dashboard’s problems pane until the problem is fixed — a state-based surface, not store-and-forward, so a resolved problem simply vanishes rather than delivering stale.
The retired queue
Earlier builds accumulated warns in a per-session systemMessage queue and
drained them into the next SessionStart / Stop hook response. That queue
retired in the TUI rework: with no drain-time revalidation it structurally
delivered expired truths (a warn arriving after the problem was already
resolved), which the TUI’s durable, state-based problems pane cannot. The
[notifications] threshold key that set its floor retired with it.
The parent-agent context leg
One hook-borne notice survives, and it is agent-facing, not user-facing: when a
subagent stops leaving a dirty worktree, the notice rides Claude Code’s
hookSpecificOutput.additionalContext on the parent agent’s next allowed
PreToolUse / Stop response (a per-session queue in parent_context.rs,
dropped on session end). The parent is the actionable audience — it can land
the work or remove the worktree.
The SystemMessageBuilder survives too, trimmed to a thin [severity] message
formatter for the one remaining direct systemMessage: the SessionStart
config-validation error (“run catenary doctor”), a fresh synchronous check,
not a queued drain.
For full configuration details, see the Notifications page.
Hook system
Catenary integrates with host CLIs via hooks — shell commands that execute at lifecycle boundaries (before/after tool use, session start/stop). Hook processes are dumb transports: they read the hook payload from the host CLI, connect to the running session’s IPC socket, forward the request, and format the response for the host.
All hook logic runs server-side. The hook process (catenary hook <subcommand>) is a thin CLI client.
Architecture
Two components split protocol concerns from application logic:
-
HookServer— protocol boundary. Listens on an IPC endpoint (Unix domain socket on Unix, named pipe on Windows), parses JSON messages, logs request/response pairs for monitor visibility, and delegates toHookRouter. Analogous toMcpServerfor MCP andConnection/LspServerfor LSP. -
HookRouter— application dispatch. Routes parsedHookRequestvalues to the appropriate handler. Owns editing state enforcement, file accumulation, root refresh signaling, and notification drain. Analogous to the IPC router for CLI command dispatch.
Hook methods
Hook methods, each corresponding to a host CLI lifecycle event:
| Method | Host event | Purpose |
|---|---|---|
session-start/clear-editing | SessionStart | Clear stale editing state from a previous agent context |
pre-tool/editing-state | PreToolUse / BeforeTool | Editing state enforcement — deny or allow a tool call |
post-agent/require-release | Stop / AfterAgent | Force catenary diagnostics if the agent stops with covered edits pending |
subagent-start/mount-worktree | SubagentStart | Mount an isolation:"worktree" subagent’s git worktree as its own worktree:{session_id}:{path} LSP root |
worktree-remove/unmount-worktree | WorktreeRemove | Tear down a worktree:* root — fires only for non-git VCS / --worktree session exit (see worktree-root teardown below) |
worktree-create/log-payload | WorktreeCreate | Best-effort observability sink — the hook forwards its full payload here so it lands in the firehose (catenary query --kind hook); worktree creation itself is a self-contained local operation (see worktree relocation below) |
Teaching-payload injection
SessionStart and SubagentStart inject the full prevention payload
into the agent’s context via hookSpecificOutput.additionalContext (a Claude
Code channel) — not a pointer to run catenary primer, but the primer’s
content inlined. One module, src/cli/teaching.rs, is the single source: the
catenary primer command and both hooks render the same
teaching::payload_body(), so the on-demand command and the pushed hook
context can never drift.
The rendering is keyed by an optional declared client: catenary primer <client> (e.g. catenary primer claude) and the hook definitions’ --format
flag declare the identity — never sniffed from a host name, since hooks are
hand-crafted per host and there is no standardized hook protocol to
auto-detect against. A client whose installed hook set registers the
WorktreeCreate hook (today Claude Code) gets a “Dispatching isolated work”
section teaching isolation: "worktree" subagent dispatch; bare
catenary primer prints the client-neutral payload.
The payload has three tiers (~600–800 tokens): the live commands surface
(the allow / pipeline / deny surface resolved from the config at emission time,
rendered by the same machinery catenary commands prints, closing with the
write-model line), the invariants (the edit→diagnostics loop, bare-only vs
pipe-friendly command classes, the glob quoting / pattern-path form), and
compact flag synopses for grep/glob, each ending in a
full: catenary <cmd> --help breadcrumb. It names no catenary primer /
catenary commands pointer — inlining is the point.
SessionStart re-injects across context discontinuities: it fires on
startup / clear / compact (re-stamping the payload the discontinuity may
have dropped) and skips only resume (which restores the prior transcript
verbatim). SubagentStart fires once per subagent spawn and appends a
per-agent debt line (a subagent’s diagnostic debt is tracked per-agent); its
additionalContext lands in the subagent’s own window under one shared label,
so the payload is self-contained and prefix-identifiable.
Hook contracts by host
Different host CLIs have different hook surfaces. The hook definitions live in host-specific JSON files:
| Host CLI | Hook file | Events |
|---|---|---|
| Claude Code | plugins/catenary/hooks/hooks.json | SessionStart, PreToolUse, Stop, SubagentStop, SessionEnd, SubagentStart, WorktreeRemove, WorktreeCreate, PermissionRequest |
| Antigravity CLI | plugins/catenary-antigravity/hooks.json | PreInvocation, PreToolUse, Stop |
The PreToolUse hook handles both editing state enforcement and command
filtering in a single invocation.
Worktree relocation (out of tree)
Claude Code’s WorktreeCreate hook lets a plugin own worktree creation: the
hook receives a JSON payload on stdin and must print the created worktree’s
absolute path on stdout (a failure or empty path fails creation). Catenary uses
it (catenary hook worktree-create) to place every subagent worktree outside
the source repo tree, under <cache_dir>/catenary/worktrees/<flattened-repo>-<id>
(see src/worktree_create.rs and paths::agent_worktree_dir). This is the
structural fix for nested-worktree index pollution: a worktree nested inside a
tracked root is a second copy of the project that gitignore-blind server
discovery (rust-analyzer’s cargo walk) indexes a second time; a worktree that
lives outside the repo can never be reached by that downward walk, with zero
per-server exclude configuration.
Relocation is transparent to the rest of the worktree lifecycle: a git worktree
records its upstream repo through a .git file (gitdir: <repo>/.git/worktrees/<name>), not its filesystem location, so the
SubagentStart mount predicate (worktree_to_auto_mount, which resolves the
worktree’s canonical project root from that pointer) and the deletion watch both
work unchanged for a cache-dir worktree. Cleanup is unchanged too: Claude Code
removes git worktrees itself with git worktree remove, which drops the
directory and its .git/worktrees/<name> metadata regardless of where the
directory lives. As a crash-safety backstop, each create first runs
git worktree prune semantics over the cache dir (worktree_create::prune_orphans),
sweeping any directory whose git linkage is already dead.
Because the hook replaces Claude Code’s default worktree creation entirely,
two host behaviors are reimplemented in worktree_create.rs. First,
.worktreeinclude: the host normally copies untracked, git-ignored local config
(the .env class) into each new worktree per a <repo>/.worktreeinclude file
(.gitignore pattern syntax); copy_worktree_includes carries the matched
files into the relocated worktree, preserving relative paths and skipping any
path already checked out (so tracked files are never clobbered). Second, VCS
detection and per-VCS creation: before any VCS call, detect_vcs examines the
payload cwd (and its ancestors) for a marker, and each supported VCS gets its
worktree-shaped analog under the agents scheme —
- git —
git worktree add(shared object store, separate working dir); - hg —
hg share(the true worktree analog: a shared store with a separate working dir), falling back tohg clonewhen the bundledshareextension is unavailable; - svn —
svn checkoutof the source working copy’sURL@revision(a second, independent working copy). svn has no shared-store worktree concept, so local uncommitted changes in the source do not carry over — an inherent parity gap, surfaced honestly at creation (a user notification) rather than hidden.
.jj keeps the single honest refusal line naming the detected VCS (decision-030
gate still closed — no jujutsu binary on the host), and an unversioned directory
gets the marker-list error rather than a raw VCS error. The sidecar records the
backing VCS and its per-VCS base marker (git HEAD, svn URL@revision, hg base
changeset), which the disposal clean proof consumes (misc 148).
Worktree-root teardown
A worktree subagent’s root is mounted at SubagentStart and meant to be
torn down at WorktreeRemove. But WorktreeRemove never fires for git
worktrees: the host runs git worktree remove itself and invokes the
hook only for the non-git VCS / --worktree session-exit path. So for the
common git case the prompt teardown signal is the directory deletion
itself. For a non-git (svn/hg) worktree the host does fire
WorktreeRemove and expects the hook to delete the copy: the handler runs
the same guarded disposal routine (worktree_dispose::dispose with
host_initiated) that every other trigger uses — a clean copy is deleted,
a dirty one is refused with a logged divergence, and a path outside our
scheme or lacking a sidecar is never touched (misc 148). This is the live
leg of the arming that stays dormant for git. The daemon reaps the worktree:{session_id}:{path} root via a
bounded, non-recursive directory-deletion watch (notify/inotify) on each
mounted worktree dir — registered at mount, fired the instant the dir
disappears (see src/worktree_watch.rs). The hourly dir-gone GC
(reap_missing_worktree_roots) and the SessionEnd sweep remain as
crash-safe backstops: the watch is in-memory and dies with the daemon. The
reap (remove_contributor + root re-sync) is identical and idempotent
across all three paths, so a double-reap is a harmless no-op.
The --format=claude / --format=antigravity flag on each catenary hook
command selects the output format for the host’s expected JSON structure.
Diagnostic delivery path
Diagnostics flow through catenary diagnostics stdout output. The
PreToolUse hook tracks which files the agent modifies during editing
mode; catenary diagnostics collects those paths and runs the batched
diagnostic pipeline.
The current path:
PreToolUsehooks track modified file paths inEditingManager(viaHookRouter) during editing mode.- The agent runs
catenary diagnostics(CLI command via the host’s shell tool). DiagnosticsServerruns the batched diagnostic pipeline across all accumulated files.- Results are printed to stdout — the agent sees them in the shell tool output.
This keeps diagnostics in the agent channel (where the agent can act on them) and hook responses in the user channel (where operational information belongs). See Document Lifecycle & File Watching for the full diagnostic pipeline.
TUI — the catenary dashboard
Running catenary with no subcommand in an interactive terminal launches
the TUI dashboard. (When stdin and stdout are pipes — an MCP client
launched it — the same binary serves MCP instead, no flag needed.) The
dashboard is a pure file reader: it reads the daemon-owned state.json
snapshot and never connects to the daemon process, the firehose, or a
database. It is a read-only observer — it cannot affect a running session.
For full protocol and trace history (a single session, a server, a
search), query the firehose with catenary query — see
CLI & Dashboard.
Data source
The TUI reads a single file: the daemon-owned state.json snapshot under
runtime_dir. A file watcher on the snapshot’s directory triggers a
reload whenever the daemon rewrites it. The TUI never polls and never
opens the firehose — it wakes only when the snapshot changes. The
snapshot holds live state only (current sessions, servers, recent alerts
and activity); historical protocol traffic lives in the firehose and is
read with catenary query.
Firehose record
Every event is appended to the firehose as one JSON line. Empty or absent fields are omitted; the principal fields are:
| Field | Purpose |
|---|---|
ts | Timestamp (RFC 3339, millisecond, UTC) |
kind | Event kind: lsp, mcp, hook, or internal |
level | Tracing severity (debug, info, warn, error) |
scope_id | Self-describing shard id: session id, search UUID, server@root, or instance id |
parent_id | Causation ID pairing a response with its request |
server | LSP server name (e.g., rust-analyzer) |
scope_root | Workspace root the event was routed for |
cwd | Originating working directory (the catenary query --cwd filter dimension) |
method | Protocol method (e.g., textDocument/hover) or, for internal events, the module target |
source | Subsystem taxonomy (e.g., lsp.lifecycle) |
payload | Nested protocol JSON (protocol events) |
message / language / fields | Rendered message and remaining structured fields (internal events) |
Three boundary components own logging: McpServer (MCP), LspClient
(LSP), and HookServer (hooks). Application servers are black boxes —
the protocol messages that went in and came out are linked by
parent_id in the firehose records.
Panes
The dashboard is a snapshot renderer: it shows the daemon’s current state
directly, with no message-stream reconstruction (no request/response
pairing or scope collapse — that view is catenary query’s job). It is a
health/config surface, not a stream monitor, and answers a single binary
question: is it working? Its inputs are state.json and the health
model’s findings; it never probes an LSP or opens the firehose. Four
panes share a 2×2 master-detail grid:
- Servers (by root) (top-left) — grouped by root (the lifecycle/RAM unit), collapsible; root lines carry the schema-2 contributor labels and ephemeral idle countdowns, with per-root server rows (lifecycle / time-in-state / respawns / last-death) and dormant inventory behind a toggle. A healthy fleet collapses to one line per root — nothing green shouts.
- Sessions (by client) (bottom-left) — grouped by client, with
install-health findings inline at the client node and live sessions
underneath. Session status is capability-aware: each host renders only
what its events feed (Claude Code adds subagent sub-rows; a host with
no stop coverage degrades to an honest
last seen Nm— no fabricated statuses). - Details (Servers / Sessions) (top-right) — the contextual view of the cursored node, titled for the focused tree: a server’s config, live instances, and its findings with routing provenance, a root’s routing table, or a session’s recent actions + live subagents.
- Problems pane (bottom-right) — the durable notification surface:
every finding sorted
Fatal/Error/Warningwith its fix-it line, suggestions as a collapsed tail that never displaces a problem. The pane title carries the one-line verdict (● working/✗ N problems · M suggestions). Selecting a problem focuses the board on its owner. An empty pane is the working verdict.
Findings render twice — inline at their owning tree node and in the problems pane — from one health model, so the two views can never disagree. There is no header strip: the verdict rides the Problems pane title, and the footer carries the daemon pid, version + skew, and snapshot freshness.
Suggestion and Fatal findings are activity-gated (tui-rework 09): a
language is live only when tracked-session activity has touched a file of
it — the daemon records the touch into the snapshot’s
activity_languages ledger, and the health model reads that rather than
scanning the filesystem for presence. A dormant fixture directory no
session opened lights nothing; a server the daemon spawned on mere
presence and that then failed is quiet dormant Info, not a Fatal, unless
its language is activity-live or the server is explicitly configured. The
provenance (routed by <file> (N files) in <root>) renders under the
finding’s fix-it line so “why is this being probed?” is always answerable.
Layout
Shared borders divide the grid; each pane’s title renders inside its
body, not on the border. On narrow terminals the grid degrades to four
full-width stacked panes. Navigation is keyboard-first with mouse click
as an equal path: j/k to move, Tab to cycle panes, Enter to
expand a node or focus a problem’s owner, p for problems-only, d to
toggle dormant inventory, g/G and PageUp/PageDown to jump, y to
yank the selected entry via OSC 52, ? for the keybinds panel, q to
quit. All colors use the terminal’s ANSI palette, so the TUI inherits the
user’s theme; every severity also carries a glyph so it reads on a
monochrome terminal.
This snapshot dashboard replaced an earlier message-stream TUI: with a shared daemon and many concurrent sessions, reconstructing every session’s protocol stream in the UI did not scale and coupled the TUI to a growing database. Reading a small live snapshot instead keeps the dashboard structurally unable to wedge the daemon.
For keybindings and usage, see the CLI & Dashboard page.
Tracing conventions
LoggingServer routes events based on structured fields. The
Tracing Conventions page defines the
severity guidelines, reserved field names, and source taxonomy that
all code must follow. Key rules:
error!()fires a desktop notification (the urgent interrupt) and a TUI health finding;warn!()fires a TUI health finding (no interrupt). Both land in the firehose. Only use these for user-relevant, actionable conditions. The one exception is server-forwarded window messages (source = lsp.logging, fromwindow/logMessage/window/showMessage): those are firehose-only and never a desktop interrupt (misc 125).- The
kindfield ("lsp","mcp","hook") marks protocol events in the firehose. Internal events (nokindfield) carrytype = "internal". - The
source,server, andlanguagefields should be included onwarn!()/error!()events so the firehose query surface and the health finding key cleanly on identity.
Related pages
- Session Lifecycle — when
LoggingServeris constructed and activated, when hooks bind to the IPC socket. - Configuration Model —
[notifications]configuration. - Document Lifecycle & File Watching — editing mode
enforcement and the
catenary diagnosticspipeline. - Routing & Dispatch — dispatch errors surface via
warn!()through the tracing pipeline. - LSP Client Layer — server lifecycle state transitions that produce notifications.
Tracing Conventions
Catenary uses the tracing crate for all logging and telemetry.
LoggingServer subscribes to every tracing event and dispatches to
its sinks: the per-root-sharded JSONL firehose (catenary query,
everything), the desktop-notification sink (error severity, the urgent
interrupt), and — in the daemon — the state.json snapshot writer (the
TUI health surface). The store-and-forward systemMessage notification queue
retired in the TUI rework: warns no longer interrupt a transcript, they
persist as health findings on the dashboard.
Severity guidelines
Severity chooses the delivery channel:
error!()— reaches the desktop-notification sink (the OS-level urgent interrupt, deduped per daemon lifetime, suppressible via[notifications] desktop = falseorCATENARY_NOTIFY=0) and the TUI health surface and the firehose.warn!()— reaches the TUI health surface (a warn is a health finding — stale hooks, version skew, 027 degradation) and the firehose. It no longer interrupts.info!()/debug!()— the firehose only.
Choose severity by asking:
| User cares? | Actionable? | Interrupt-worthy? | Severity |
|---|---|---|---|
| No | — | — | debug!() |
| Yes | No | — | info!() |
| Yes | Yes | Yes (systemic failure) | error!() |
| Yes | Yes | No (recoverable / a health finding) | warn!() |
Use error!() only for conditions that indicate a systemic failure
(e.g., root resolution failed, critical I/O error) — an error fires a desktop
notification, so it must earn the interrupt. Use warn!() for
degradation that the user should know about but that Catenary can
recover from (e.g., server died, roots/list failed); it surfaces durably on
the dashboard rather than interrupting.
Server-forwarded events are firehose-only
Events forwarded verbatim from an LSP server’s window/logMessage or
window/showMessage are tagged source = lsp.logging at the forwarding
site. A server’s showMessage type 1 maps to error, but it is still just
that server’s own chatter about itself — not Catenary’s own user-actionable
event — so the maintainer ruled (CatenaryInternal misc 125) it is
firehose-only and belongs on the TUI’s secondary Activity/Alerts surface, never
the desktop interrupt. It stays fully queryable in the JSONL firehose; a
genuinely broken server surfaces where it matters (a routed-but-broken server
is a health finding).
Reserved structured fields
kind — "lsp" | "mcp" | "hook" — marks a protocol event in the firehose
method — Protocol method name (LSP/MCP method)
server — LSP server name ("rust-analyzer", "pylsp", ...)
client — Client identifier ("claude-code", "antigravity")
request_id — In-process correlation id (i64)
parent_id — Correlation id of the causing event (i64)
source — Subsystem that emitted the event (see taxonomy below)
language — Language id ("rust", "python", ...)
payload — Raw protocol JSON string (for kind = lsp|mcp|hook)
warn!()/error!() events should carry source, server, and language
where applicable: they key the firehose query surface (catenary query) and
the health finding an event maps to, so an event with a stable identity groups
cleanly instead of scrolling by as noise.
Source taxonomy
The source field uses a fixed two-level subsystem.concern taxonomy.
The Source enum in src/source.rs is the single source of truth;
convenience constants are derived from it for use in tracing macros.
Subsystems
| Subsystem | Scope |
|---|---|
config | Configuration loading and validation |
daemon | Daemon process (socket listeners, connection management) |
hook | Hook layer (pre/post tool hooks) |
logging | Logging infrastructure itself |
lsp | LSP client layer (server communication, lifecycle, routing) |
mcp | MCP server layer (host communication, dispatch) |
Concerns
| Concern | Meaning |
|---|---|
bootstrap | Startup sequencing |
dispatch | Message routing, method dispatch, capability checks |
lifecycle | Spawn, init, crash, recovery, shutdown |
logging | Forwarded server window messages (window/logMessage, window/showMessage) |
parse | Parsing and deserialization |
stderr | Raw server process stderr output |
validation | Semantic correctness checks |
Valid combinations
| Source | Description | Constant |
|---|---|---|
config.parse | Config loading errors (TOML parsing, deserialization) | ConfigParse |
config.validation | Semantic config errors (orphan servers, unsupported keys) | ConfigValidation |
daemon.dispatch | Connection accept, correlation, session routing | DaemonDispatch |
daemon.lifecycle | Daemon startup, shutdown, signal handling | DaemonLifecycle |
hook.dispatch | Hook request routing and dispatch | HookDispatch |
logging.bootstrap | Logging infrastructure startup sequencing | LoggingBootstrap |
lsp.dispatch | LSP message routing, method dispatch, capability checks | LspDispatch |
lsp.lifecycle | Server spawn, init, crash, recovery, shutdown | LspLifecycle |
lsp.logging | Server window/logMessage / window/showMessage telemetry (firehose-only, never a desktop interrupt) | LspLogging |
lsp.stderr | Raw server process stderr output | LspStderr |
mcp.dispatch | MCP message dispatch and roots handling | McpDispatch |
Not every subsystem uses every concern. Only the combinations listed
above are valid. New values must be added as variants to the Source
enum in src/source.rs.
Protocol events
Protocol boundary components (McpServer, Connection/LspServer,
HookServer) emit structured tracing::info!() events with kind,
method, request_id, parent_id, and payload fields. At info
severity they land in the firehose only — never a desktop interrupt.