Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

ChangeAffects you if…Action
Writes resolve-or-deny; allow_file_redirects retiredyour config set allow_file_redirectsdelete the key — the write model is now automatic
awk/sed dropped from the default pipelineyour [commands].pipeline lists awk or sedremove them; sweep with native sed -i / perl -i -pe
Project [commands] enforcement keys ignoredyou set enforcement keys in a .catenary.tomlmove 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_only
  • allow
  • pipeline
  • deny
  • deny_flags
  • allow_flags
  • script_hosts
  • guidance

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 exits 0 and 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
  • thresholdremoved. Warns now persist on the TUI health dashboard (a warn is a health finding) and everything is queryable via catenary query; neither is severity-tunable. A leftover threshold does not break startup — it is ignored — but catenary doctor flags it as an unknown key, so delete it.
  • desktop (default true) — fire OS-level desktop notifications for error-severity events (the urgent interrupt). CATENARY_NOTIFY=0 also 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 start step (it remains an idempotent no-op).
  • editing stop is now catenary diagnostics — it ends the edit batch and prints diagnostics for every modified file.

Installation

Prerequisites

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)? Run cargo uninstall catenary-mcp first — ~/.cargo/bin usually precedes brew’s bin dir on PATH, 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 catenary binary 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 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.catenary entry and an instructions reference to a rules file into your opencode.json, and shipped a static ~/.config/opencode/catenary.md teaching 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 merged mcp.catenary / instructions entries, delete ~/.config/opencode/catenary.md, and drop any old instructions entry naming it. Leaving the merged mcp.catenary entry 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 with OPENCODE_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:

StatusMeaning
readyServer spawned, initialized, and capabilities listed
command not foundBinary not on $PATH
spawn failedBinary found but process failed to start
initialize failedProcess 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

  1. Configure your language servers
  2. Install language servers for your languages

Configuration

Catenary loads configuration from multiple sources, in order of priority (last wins):

  1. 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.
  2. User config: ~/.config/catenary/config.toml.
  3. Project config: .catenary.toml in each workspace root. Discovered when roots are added (at startup or via catenary pin). Scoped to [lsp] (the disable toggle plus [lsp.server.*] / [lsp.language.*] definitions), [linter] (disable plus [linter.rule.*]), [diagnostics] (disable), and [commands] build only — every other [commands] key and all other sections are user-level (see Project-scoped commands).
  4. Explicit file: --config <path>.
  5. 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.*].diagnosticsPer-binding diagnosticsEffective
unset / trueunset / truedeliver
falseanysuppress (language-wide)
unset / truefalsesuppress (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 + crate Cargo.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 old lsp = false.)
  • [linter] disable — drops the standalone-linter feeder: no linter diagnostics for this root.
  • [diagnostics] disable — suppresses the diagnostics surface (the editing→catenary diagnostics gate 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 lsp key (and its old enabled alias) was removed in 2.0. Replace lsp = false with a [lsp] table carrying disable = true — the polarity flips. A leftover lsp/enabled key is now a hard error, flagged by catenary doctor.

Merge Semantics

Project config is deep-merged with user config at the key level:

  • Scalars replacepath, args, min_severity.
  • Tables deep-merge by key — a project [lsp.server.rust-analyzer] with just settings inherits path and args from the user’s (or built-in) [lsp.server.rust-analyzer].
  • Arrays replaceservers, 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

ExtensionLanguage ID
.rsrust
.gogo
.cc
.cpp, .cc, .cxx, .h, .hppcpp
.zigzig
.dd
.vv
.nimnim
.javajava
.kt, .ktskotlin
.scala, .scscala
.groovy, .gvygroovy
.clj, .cljs, .cljcclojure
.cscsharp
.fs, .fsx, .fsifsharp
.swiftswift
.m, .mmobjective-c
.pypython
.rbruby
.pl, .pmperl
.phpphp
.lualua
.tcltcl
.crcrystal
.js, .mjs, .cjsjavascript
.ts, .mts, .ctstypescript
.tsxtypescriptreact
.jsxjavascriptreact
.hs, .lhshaskell
.ml, .mliocaml
.elmelm
.gleamgleam
.ex, .exselixir
.erl, .hrlerlang
.purspurescript
.sh, .bash, .zsh, .ebuild, .eclass, .installshellscript
.fishfish
.ps1, .psm1, .psd1powershell
.r, .Rr
.jljulia
.mojomojo
.html, .htmhtml
.csscss
.scssscss
.sasssass
.lessless
.sveltesvelte
.vuevue
.json, .jsoncjson
.yaml, .ymlyaml
.tomltoml
.xml, .xsl, .xslt, .xsdxml
.sqlsql
.graphql, .gqlgraphql
.protoproto
.md, .mdxmarkdown
.rstrestructuredtext
.tex, .latexlatex
.typtypst
.nixnix
.tf, .tfvarsterraform
.cmakecmake
.dartdart
.dockerfiledockerfile

By filename

FilenameLanguage ID
Dockerfiledockerfile
Makefile, GNUmakefilemakefile
CMakeLists.txtcmake
Cargo.toml, Cargo.locktoml
Gemfile, Rakefileruby
Justfile, justfilejust
PKGBUILDshellscript

By shebang

For files without a recognised extension, Catenary reads the first line. If it starts with #!, the interpreter name is matched:

InterpreterLanguage ID
bash, sh, zsh, dash, kshshellscript
fishfish
python, python3, python2python
node, nodejsjavascript
denotypescript
ruby, irbruby
perlperl
phpphp
lua, luajitlua
tclsh, wishtcl
Rscriptr
juliajulia
elixir, iexelixir
erlerlang
swiftswift
kotlinkotlin
scalascala
groovygroovy
crystalcrystal

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

  1. No [commands] section — not configured yet. Catenary emits a hint notification once per session at startup.
  2. client_enforcement_only = true — deliberate opt-out. No hint, no enforcement.
  3. allow = [...] present — active allowlist. Enforce.
[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

KeyDescription
client_enforcement_onlyDeliberate opt-out — no enforcement, no hint notification.
buildThe project’s build tool (e.g., "make"). On denial of a build-related command, the response directs the agent to the configured build tool.
allowCommands the agent can run unconditionally (including read/stdout-only tools — reads are not a write vector).
pipelineCommands 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_hostsCommands 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 (grepcatenary grep, globcatenary 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

OptionDefaultDescription
log_retention_days7Days 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):

SideFormMeaning
Matcher"*"Matches any declared root.
Matcherliteral pathMatches 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.
Templateliteral pathA 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
OptionDefaultDescription
desktoptrueFire 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
OptionDefaultDescription
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).

LinterRoutes onInvocationCode
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 = true on a [linter.rule.<name>] to drop that one linter, or set [linter] disable = true in a project .catenary.toml to 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

FieldTypeDescription
commandstringThe executable to run (required).
argslistArguments passed before the file paths.
patternslistRoot-relative path globs selecting which files this linter handles. Not filename globs — an unanchored *.yaml would fire on every YAML in the tree.
shebangslistInterpreter basenames (["bash", "sh"]) that additionally route an extensionless script by its #! line. Empty ⇒ shebang routing off.
disableboolDrops this linter for the root it resolves under (default false).
weightintegerDiagnostic 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.

PresetDescription
unicode (default)Safe symbols for any terminal font.
nerdNerd 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:

SeverityDesktop notificationTUI health surfaceFirehose
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 = false or the CATENARY_NOTIFY=0 environment 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 doctor renders the same findings one-shot.
  • The firehose (src/logging/jsonl_sink.rs) records every event, queryable after the fact with catenary 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):

KeyAction
j / DownMove down one entry
k / UpMove up one entry
TabFocus the next pane
Shift+TabFocus the previous pane
EnterExpand/collapse a node, or focus a problem’s owner
pProblems-only — collapse both trees to broken things
dToggle the dormant-server inventory
g / HomeJump to the first entry
G / EndJump to the last entry
PageDown / PageUpPage down / up
yYank the selected entry (scope id / text) via OSC 52
?Toggle the keybinds help panel
qQuit

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.

FlagDescription
[PATH]...File or directory path(s) to scope the search (quoted globs allowed)
--glob <pat> / -gInclude only files matching this glob (repeatable; !pat excludes)
--type <ty> / -tInclude only files of this ripgrep type, e.g. rust, md (repeatable)
--exclude-pattern <pat>Glob pattern to exclude from matches
--ignore-case / -iCase-insensitive matching (overrides smart-case)
--case-sensitive / -sCase-sensitive matching (overrides smart-case)
--word-regexp / -wMatch whole words only
--fixed-strings / -FTreat the pattern as a literal string, not a regex
--invert-match / -vSelect non-matching lines
--files-with-matches / -lPrint only the paths of files containing a match
--after-context <n> / -AShow n lines of context after each match
--before-context <n> / -BShow n lines of context before each match
--context <n> / -CShow n lines of context before and after each match
--count / -cReport the match count instead of results
--include-gitignoredInclude files ignored by .gitignore
--include-hiddenInclude 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.

FlagDescription
[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
--countReport the path count instead of results
--include-gitignoredInclude files ignored by .gitignore
--include-hiddenInclude 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 diagnostics is the gate verb, and there is no gate to pay: it errors with a teaching message and exits 2 (a fault, not a clean empty receipt). Naming what you want diagnosed is the fix.
  • Scoped catenary diagnostics <path…> — including catenary 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 — doctorpindiagnostics . 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
FlagDescription
--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-instancesRead every instance dir, not just the freshest
--followLive-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
SubcommandDescription
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.
lsList 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.

FlagDescription
[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)
--diffShow a unified diff for every stale host file (hooks.json, the constrained-bash helper)
--nocolorDisable 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.

FlagDescription
--forceStop 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
FlagDescription
--checkPrint whether an update is available without downloading
--forceRe-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)PageServer
CSS, HTML, JSONCSS-HTML-JSONvscode-langservers-extracted
GoGogopls
JavaScriptJavaScripttypescript-language-server
JuliaJuliaLanguageServer.jl
MarkdownMarkdownlattice
PHPPHPintelephense
PythonPythonpyright-langserver
RustRustrust-analyzer
Shell (Bash)Shellbash-language-server
Termux & PackagingTermuxtermux-language-server
TypeScriptTypeScripttypescript-language-server

Contributing

Want to add a language?

  1. Create a page for your language in the lsp/ folder following the template below
  2. Add a row to the table above
  3. 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:

ServerLanguages
vscode-css-language-serverCSS, SCSS, Less
vscode-html-language-serverHTML
vscode-json-language-serverJSON, JSONC
vscode-markdown-language-serverMarkdown
vscode-eslint-language-serverESLint

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)

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

  • gopls is 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

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, .cjs files
  • Provides type inference even in plain JavaScript
  • Add a jsconfig.json to customize project settings

JSX / React

JSX is handled automatically. The built-in defaults bind typescript-language-server to javascriptreact for .jsx files.

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=no and --history-file=no for 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()"]

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"]:

  • .git roots 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 lattice on PATH. Without it the daemon emits a benign Failed to spawn LSP server: lattice warning — not a wedge; every other server and all of Catenary’s core (grep/glob/diagnostics on other languages) is unaffected.

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"]

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.

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-analyzer on PATH is the rustup proxy, which dispatches to the toolchain’s own rust-analyzer — so it tracks a project-level rust-toolchain.toml automatically
  • First run on a project may take time to index (watch for “Indexing” status)

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, not bash or sh
  • Works with .sh, .bash, .zsh files
  • 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.

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

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-server for specialized formats
  • Supports file types like PKGBUILD, build.sh, *.ebuild, and *.mdd

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 typescript as a peer dependency
  • Works with .ts, .tsx, .mts, .cts files
  • Reads your tsconfig.json for project settings

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 glob for search; catenary diagnostics for the batched diagnostic report (editing is tracked implicitly — the first edit starts it, there is no start step). Mass edits go through native sed -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 hold Arc<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-server workspace/didChangeWatchedFiles changed-set (see Document Lifecycle & File Watching).
  • LspClientManager — LSP server lifecycle. Spawns, caches, and shuts down LspClient instances. 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.json snapshot under runtime_dir, rewritten on change; the full protocol and trace history streams to an append-only, sharded JSONL firehose under cache_dir, read by catenary query. The durable Unix sockets live under state_dir. A legacy catenary.db from older versions is deleted on daemon startup. See src/paths.rs.

Topic pages

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:

  1. Socket bind. The daemon binds two deterministic Unix sockets under state_dir: catenary/catenary-mcp.sock (MCP traffic from the bridge proxy) and catenary/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 legacy catenary.db is deleted here.

  2. LoggingServer activation. Constructed earlier in buffering mode (early tracing events 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 daemon state.json snapshot.

  3. Config loading. Config::load() reads sources in order: embedded default language definitions, user config (~/.config/catenary/config.toml), and an optional explicit file (CATENARY_CONFIG env var). Later sources override earlier ones; environment overrides (CATENARY_SERVERS, CATENARY_ROOTS) are applied last.

  4. Root resolution. Workspace roots come from CATENARY_ROOTS (path-separated) or default to the current directory. Roots are canonicalized to absolute paths.

  5. Primary session assembly. The daemon builds one shared, infrastructure-only Session. It owns the resources every agent shares: the LspClientManager (the LSP server pool), the tool servers (GrepServer, GlobServer, DiagnosticsServer), the SymbolIndex (populated lazily from LSP documentSymbol responses), and the JSONL firehose sink. FilesystemManager is 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.

  6. spawn_all. The client manager walks workspace roots, classifies files via FilesystemManager, 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::Workspace instance with all roots. Legacy servers get a separate Scope::Root instance per root.
    • Project-scoped roots (those with a .catenary.toml that overrides the language’s server config) get their own Scope::Root instance and are excluded from the workspace instance via didChangeWorkspaceFolders.
  7. Accept loop. SessionManager begins 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. McpServer handles the MCP lifecycle (initialize, roots, ping) per connection and exposes no application-level tools; an on_roots_changed callback 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:

  1. 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) — via workspace/didChangeWatchedFiles to servers whose registered watchers match.

  2. IPC dispatch. The IPC router dispatches the request to the appropriate application server:

    • grepGrepServer — parallel ripgrep + LSP symbol index search, LSP enrichment.
    • globGlobServer — file listing with structural symbol outlines from LSP documentSymbol.
    • 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).
  3. LSP interaction. Application servers use LspClientManager to 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).

  4. 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:

  1. FilesystemManager roots are updated; a newly added root has no baseline yet, so its first walk becomes the baseline (no re-seed).
  2. Project configs are loaded for new roots; classification tables are updated.
  3. Workspace-capable servers receive didChangeWorkspaceFolders notifications (additions for non-project-scoped roots, removals for roots that disappeared).
  4. Per-root settings from project configs are sent via didChangeConfiguration.
  5. Legacy servers get new Scope::Root instances spawned for added roots and existing instances shut down for removed roots.
  6. spawn_all runs again to detect languages in new roots.

Shutdown

The daemon exits when the last client disconnects, on catenary stop, or on SIGINT/SIGTERM:

  1. The accept loop stops and both socket listeners are torn down.
  2. Session::shutdown() sends LSP shutdown requests to all active servers, waits for responses, then sends exit notifications.
  3. The JSONL firehose is flushed — the queue drains and the writer thread joins.
  4. Dropping the SessionManager removes both daemon sockets (catenary.sock and catenary-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:

  1. Multiple servers per language. PKGBUILD files are shellscript, but they benefit from both termux-language-server (package-specific hover, diagnostics) and bash-language-server (shell fundamentals — definitions, references, symbols). A single [lsp.language.shellscript] entry can’t hold two server definitions.

  2. One server for multiple languages. clangd serves both C and C++. Under the old model, its command, args, and settings had 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:

  1. 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.

  2. User config (~/.config/catenary/config.toml) — full config. Adds server bindings, server definitions, and all other sections ([commands], [notifications], [icons], [tools]).

  3. Project config (.catenary.toml per workspace root) — scoped to [lsp.language.*], [lsp.server.*], and [commands]. Discovered at root addition time. See Project config scope below.

  4. Explicit file (CATENARY_CONFIG env var or --config flag) — full config that overrides the user config.

  5. 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 only settings inherits path and args from 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 resolves cwd (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 contribute build, 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 = false to 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 — = false is 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.toml per 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):

  1. Shebang — the file’s #! line declares its interpreter. Matched against the shebangs field on [lsp.language.*].
  2. Filename — exact filename match against the filenames field.
  3. Extension — file extension match against the extensions field.

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 hasUser hasResult
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.

  • 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):

  1. Shebang — the file’s #! line declares its interpreter.
  2. Filename — exact filename match (e.g., PKGBUILD, Makefile).
  3. 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 uses Scope::Workspace and receives didChangeWorkspaceFolders notifications as roots are added or removed.

  • Legacy servers (no workspaceFolders support) get a separate instance per root, each using Scope::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: clangd serving 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-server and bash-language-server for 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:

VariantMeaning
WorkspaceShared across roots. One instance per (language, server) pair.
Root(PathBuf)Bound to a specific root. Used for legacy servers and project-scoped instances.
SingleFileTier 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:

  1. Check capability — does this server support the method?
  2. Send request.
  3. If the response is non-empty, return it. Done.
  4. 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 the servers list suppresses diagnostics from that server for that language.
  • Language-level: diagnostics = false on [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:

  1. Classification. PKGBUILD has no extension. No shebang. Filename match against [lsp.language.shellscript] filenames → language is shellscript.

  2. Root resolution. FilesystemManager::resolve_root finds the owning workspace root via longest-prefix match.

  3. Language config lookup. [lsp.language.shellscript] has:

    servers = ["termux-language-server", "bash-language-server"]
    
  4. file_patterns filter. termux-language-server has file_patterns = ["PKGBUILD", "*.ebuild"]PKGBUILD matches. bash-language-server has no file_patterns — passes by default.

  5. Instance lookup. For each server, find_instance checks Scope::Root(root) then Scope::Workspace. Returns the live client for each.

  6. Capability check. Both servers support textDocument/references. Both pass.

  7. Priority chain dispatch. termux-language-server is first in the list. Send textDocument/references. If it returns results, done. If empty, fall through to bash-language-server.

  8. Diagnostics (separate path). Both servers have the file open (assuming diagnostics are enabled for both bindings). termux-language-server reports package validation issues. bash-language-server reports 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:

  1. Classifies the file to a language ID.
  2. Resolves the owning workspace root — or, for a file outside all roots, falls through to the single-file tier.
  3. Looks up the language config for server bindings.
  4. Iterates bindings in priority order, filtering by per-binding disabled_methods (when a method is given), file_patterns, instance liveness, and the capability predicate.
  5. 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.

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 oneshot channels. Notifications and server-initiated requests are forwarded to LspServer via on_notification and on_request.

  • Failure detection. Connection::request does not use a simple wall-clock timeout. Instead, it polls a ProcessMonitor (from the catenary_proc crate) 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) and RequestCancelled (-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 CancellationToken fires. Connection::request sends $/cancelRequest to the LSP server and returns a RequestCancelled error, which propagates back through the MCP response.

  • Process lifecycle. Connection::new spawns the process with set_parent_death_signal (so the server dies if Catenary dies) and registers it for cleanup. Drop kills 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 by set_capabilities from the InitializeResult. Return false before initialization completes — conservative by default.

  • Dynamic capabilities. supports_pull_diagnostics uses AtomicBool instead of OnceLock because it can downgrade at runtime. If textDocument/diagnostic fails repeatedly on a server that advertised diagnosticProvider, the capability is permanently disabled via downgrade_pull_diagnostics and the server falls back to push diagnostics.

  • Dynamic registration. Handles client/registerCapability and client/unregisterCapability for two registration types: workspace/didChangeWatchedFiles (file watcher patterns stored per registration ID) and workspace/didChangeConfiguration (tracked as a registration ID set).

  • Notification dispatch. on_notification routes 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_request handles server-initiated requests:

    • workspace/configuration — resolves settings with scopeUri awareness (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 the InstanceKey.

  • Configuration. User-level settings (immutable after construction) and settings_per_root (Mutex<HashMap>, updated mid-session when roots are added). resolve_configuration does longest-prefix match for scopeUri resolution, 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_document returns (first_open, version): first open sends didOpen, subsequent opens send didChange.

  • Position encoding. Negotiated during initialize (defaults to UTF-16 per spec).

  • Causation tracking. parent_id links all LSP messages to their originating CLI command for database correlation and TUI display.

  • Cancellation. cancel: CancellationToken — set before each tool dispatch, propagated to Connection::request.

Why three layers

The separation is not arbitrary. Three concrete benefits:

  1. Connection is 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.

  2. LspServer is inspectable without I/O. get_servers reads capability flags and lifecycle state on the Arc<LspServer> without acquiring the LspClient mutex. The routing layer can filter servers by capability without blocking on in-flight requests.

  3. LspClient composes both. Typed methods enforce capability checks before sending requests, and the parent_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:

  1. spawn_inner (on LspClientManager) creates the LspServer, spawns the Connection (child process + reader loop), constructs LspClient, and runs initialize with the workspace roots.

  2. Scope determination. From the initialize response:

    • If project-scoped (Rule A — root has [lsp.language.*] in .catenary.toml): scope is forced to Scope::Root(root) regardless of capabilities.
    • If workspace-capable (workspaceFolders supported): scope is Scope::Workspace.
    • Otherwise: scope is Scope::Root(root) (legacy per-root).

    set_scope is called on LspServer, and the full InstanceKey is constructed and inserted into the client map.

    Files outside all workspace roots take a separate path: spawn_single_file sets Scope::SingleFile before initialize (the scope is known without the handshake) and initializes with a null workspace. It is gated on single_file = true in [lsp.server.*], and a server that rejects null-workspace init is negative-cached. See the single-file tier in Routing & Dispatch.

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:

StateMeaning
InitializingSpawned, init handshake not yet complete.
ProbingInit complete, server unproven. Tool requests proceed as self-tests.
HealthyProven working, idle, accepts all requests.
Busy(n)Server declared active via $/progress begin. n = in-flight token count.
FailedHealth probe failed or init error. Terminal.
DeadConnection 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:

    1. Wait for cumulative CPU ticks to advance from a pre-stimulus baseline, or any nonzero per-process delta. Either proves the server was scheduled.
    2. 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 /proc filesystem 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 CancellationToken for 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:

PrimitiveBehavior
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 to spawn_inner.
  • sync_roots — mid-session root changes. Diffs roots, notifies workspace-capable servers via didChangeWorkspaceFolders, spawns / shuts down per-root instances, loads project configs.
  • get_servers — the routing entry point. See Routing & Dispatch.
  • shutdown_all — sends shutdown + exit to every live instance.

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). No didOpen, no didChange, no diagnostic retrieval per edit.
  • Path accumulation. The PreToolUse hook detects edit-tool calls and accumulates the modified file paths in EditingManager. 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 PreToolUse hook 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 — bare catenary diagnostics (all) and catenary 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, the DiagnosticsServer runs 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:

  1. 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.

  2. 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.

  3. Per-server batch lifecycle. For each server, the pipeline runs:

    • Open all filesopen_document_on for 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 Probing state, run an explicit health check.
    • didSave all — triggers flycheck on servers that only produce diagnostics on save (e.g., rust-analyzer runs cargo check on didSave).
    • Settle again — wait for flycheck to complete.
    • Retrieve diagnostics — read per-file diagnostics from the server’s cache.
    • Close alldidClose for every opened file.
  4. 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.

  5. 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. grep is O(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 ignore crate (already a FilesystemManager dependency).

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:

  1. 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; .gitignore respected via the ignore crate) and establishes that root’s baseline.

  2. diff_and_update on each walk. A later walk compares the current (path, mtime) against the baseline, produces a change set — Created, Changed, or Deleted — then merges the new mtimes back into the baseline. (The first walk’s whole observation is reported as Changed — the cold snapshot.)

  3. 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/registerCapability for workspace/didChangeWatchedFiles.

  4. Precise notification. Each server gets a single workspace/didChangeWatchedFiles carrying only its matching changes, as (uri, changeType) pairs whose changeType is 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 receiving didOpen for 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.

  • 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 Modeldiagnostics flags 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.

AudienceChannelWhat goes here
AgentTool result contentData + caller-input errors the agent can fix
User (urgent)Desktop notificationError-severity events — the interrupt
User (dashboard)TUI health surfaceWarns + 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 = false or CATENARY_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 carry kind in {lsp, mcp, hook}; internal events use kind = "internal". The level field always reflects the event’s tracing severity. This is the full audit trail, read after the fact by catenary query.

  • Snapshot alert ringwarn!() / error!() events are also folded into the alert ring of the daemon-owned state.json snapshot, 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 to HookRouter. Analogous to McpServer for MCP and Connection/LspServer for LSP.

  • HookRouter — application dispatch. Routes parsed HookRequest values 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:

MethodHost eventPurpose
session-start/clear-editingSessionStartClear stale editing state from a previous agent context
pre-tool/editing-statePreToolUse / BeforeToolEditing state enforcement — deny or allow a tool call
post-agent/require-releaseStop / AfterAgentForce catenary diagnostics if the agent stops with covered edits pending
subagent-start/mount-worktreeSubagentStartMount an isolation:"worktree" subagent’s git worktree as its own worktree:{session_id}:{path} LSP root
worktree-remove/unmount-worktreeWorktreeRemoveTear down a worktree:* root — fires only for non-git VCS / --worktree session exit (see worktree-root teardown below)
worktree-create/log-payloadWorktreeCreateBest-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 CLIHook fileEvents
Claude Codeplugins/catenary/hooks/hooks.jsonSessionStart, PreToolUse, Stop, SubagentStop, SessionEnd, SubagentStart, WorktreeRemove, WorktreeCreate, PermissionRequest
Antigravity CLIplugins/catenary-antigravity/hooks.jsonPreInvocation, 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 —

  • gitgit worktree add (shared object store, separate working dir);
  • hghg share (the true worktree analog: a shared store with a separate working dir), falling back to hg clone when the bundled share extension is unavailable;
  • svnsvn checkout of the source working copy’s URL@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:

  1. PreToolUse hooks track modified file paths in EditingManager (via HookRouter) during editing mode.
  2. The agent runs catenary diagnostics (CLI command via the host’s shell tool).
  3. DiagnosticsServer runs the batched diagnostic pipeline across all accumulated files.
  4. 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:

FieldPurpose
tsTimestamp (RFC 3339, millisecond, UTC)
kindEvent kind: lsp, mcp, hook, or internal
levelTracing severity (debug, info, warn, error)
scope_idSelf-describing shard id: session id, search UUID, server@root, or instance id
parent_idCausation ID pairing a response with its request
serverLSP server name (e.g., rust-analyzer)
scope_rootWorkspace root the event was routed for
cwdOriginating working directory (the catenary query --cwd filter dimension)
methodProtocol method (e.g., textDocument/hover) or, for internal events, the module target
sourceSubsystem taxonomy (e.g., lsp.lifecycle)
payloadNested protocol JSON (protocol events)
message / language / fieldsRendered 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/Warning with 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, from window/logMessage / window/showMessage): those are firehose-only and never a desktop interrupt (misc 125).
  • The kind field ("lsp", "mcp", "hook") marks protocol events in the firehose. Internal events (no kind field) carry type = "internal".
  • The source, server, and language fields should be included on warn!() / error!() events so the firehose query surface and the health finding key cleanly on identity.

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 = false or CATENARY_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
Nodebug!()
YesNoinfo!()
YesYesYes (systemic failure)error!()
YesYesNo (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

SubsystemScope
configConfiguration loading and validation
daemonDaemon process (socket listeners, connection management)
hookHook layer (pre/post tool hooks)
loggingLogging infrastructure itself
lspLSP client layer (server communication, lifecycle, routing)
mcpMCP server layer (host communication, dispatch)

Concerns

ConcernMeaning
bootstrapStartup sequencing
dispatchMessage routing, method dispatch, capability checks
lifecycleSpawn, init, crash, recovery, shutdown
loggingForwarded server window messages (window/logMessage, window/showMessage)
parseParsing and deserialization
stderrRaw server process stderr output
validationSemantic correctness checks

Valid combinations

SourceDescriptionConstant
config.parseConfig loading errors (TOML parsing, deserialization)ConfigParse
config.validationSemantic config errors (orphan servers, unsupported keys)ConfigValidation
daemon.dispatchConnection accept, correlation, session routingDaemonDispatch
daemon.lifecycleDaemon startup, shutdown, signal handlingDaemonLifecycle
hook.dispatchHook request routing and dispatchHookDispatch
logging.bootstrapLogging infrastructure startup sequencingLoggingBootstrap
lsp.dispatchLSP message routing, method dispatch, capability checksLspDispatch
lsp.lifecycleServer spawn, init, crash, recovery, shutdownLspLifecycle
lsp.loggingServer window/logMessage / window/showMessage telemetry (firehose-only, never a desktop interrupt)LspLogging
lsp.stderrRaw server process stderr outputLspStderr
mcp.dispatchMCP message dispatch and roots handlingMcpDispatch

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.