Skip to main content

Language Server Protocol (LSP)

Tutou runs full language servers — pyright, gopls, rust-analyzer, typescript-language-server, clangd, and ~20 more — as background subprocesses and feeds their semantic diagnostics into the post-write lint check used by write_file and patch. When the agent edits a file, it sees exactly the errors that edit introduced — not just syntax errors, but type errors, undefined names, missing imports, and project-wide semantic issues the language server detects.

This is the same architecture top-tier coding agents use. Tutou ships it self-contained: no editor host required, no plugins to install, no separate daemon to manage.

When LSP runs​

LSP is gated on git workspace detection. When the agent's working directory (or the file being edited) is inside a git repository, LSP runs against that workspace. When neither is in a git repo, LSP stays dormant — useful for messaging gateways where the cwd is the user's home directory and there's no project to diagnose.

The check is layered: in-process syntax check first (microseconds), then LSP diagnostics second when syntax is clean. A flaky or missing language server can never break a write — every LSP failure path falls back silently to the syntax-only result.

Concretely, on every successful write_file or patch:

  1. Tutou captures a baseline of current diagnostics for the file.
  2. Performs the write.
  3. Re-queries the language server, filters out diagnostics that were already in the baseline, and surfaces only the new ones.

The agent sees output like:

{
"bytes_written": 42,
"dirs_created": false,
"lint": {"status": "ok", "output": ""},
"lsp_diagnostics": "LSP diagnostics introduced by this edit:\n<diagnostics file=\"/path/to/foo.py\">\nERROR [42:5] Cannot find name 'foo' [reportUndefinedVariable] (Pyright)\nERROR [50:1] Argument of type \"str\" is not assignable to \"int\" [reportArgumentType] (Pyright)\n</diagnostics>"
}

The lint field carries the syntax-check result (microsecond in-process parse via ast.parse, json.loads, etc.); the lsp_diagnostics field carries the semantic diagnostics from the real language server. Two channels, independent signals — the agent sees a syntax-clean file with semantic problems as lint: ok plus a populated lsp_diagnostics.

Workspace trust​

Many language servers run code that the project itself ships: pyright executes the configured Python interpreter, typescript-language-server loads the project's node_modules/typescript, svelte-language-server loads svelte.config.js, rust-analyzer runs cargo check (build scripts, proc-macros) on every save, and servers such as jdtls, kotlin-language-server, elixir-ls, zls or haskell-language-server evaluate the project's build files (Gradle, mix.exs, build.zig, Cabal/Stack) when they start. That is fine for your own project, but not for a repository the agent has just cloned.

Tutou therefore treats every workspace as untrusted unless it is:

  • the git worktree of a directory you pointed Tutou at: where you launched it (cd my-app && tutou), the worktree tutou -w created, the project a Desktop or TUI session is opened in, or a gateway's terminal.cwd, or
  • a directory listed under lsp.trusted_workspaces (or any directory below one).

A cd in the agent's terminal does not move the session's workspace. Cron jobs and Kanban workers get no automatic trust, because the agent can choose their workdir or workspace; list the directories they should trust under lsp.trusted_workspaces. A checkout nested inside a trusted worktree has its own .git, so it is not trusted, and neither is a git repository at or above your home directory (a dotfiles repo there would otherwise trust everything below it). Trust covers the whole directory you pointed Tutou at, including anything later cloned into it, and lasts until Tutou exits.

In an untrusted workspace Tutou denies by default: only the servers below start, each with settings that keep it on Tutou-side tools. Every other server is skipped, including rust-analyzer, gopls, jdtls, kotlin-language-server, elixir-ls, zls, clojure-lsp, haskell-language-server, lua-language-server, terraform-ls, prisma, astro, vue-language-server (it loads the vueCompilerOptions.plugins a project's tsconfig.json names) and any server you declare under lsp.servers. The diagnostics log records skipped: untrusted workspace …; add it to lsp.trusted_workspaces, and tutou lsp status marks those servers [trusted workspaces only].

ServerUntrusted workspace
pyrightVIRTUAL_ENV or the Tutou-managed Python, never the project's .venv/venv
typescript-language-servertsserver.path pinned to the TypeScript next to the server; skipped if there is none
svelte-language-serverisTrusted: false (no svelte.config.js, no project svelte/prettier)
bash-language-server, yaml-language-server, dockerfile-ls, intelephenseunchanged: they run no project code (yaml-language-server may fetch the JSON schemas a file names)
clangdunchanged: Tutou never passes --query-driver, so no project compiler runs

On a local backend, the post-write shell linters that would use the checkout's own toolchain are skipped the same way whenever the terminal's current directory is untrusted: npx tsc (it runs the repository's node_modules/.bin/tsc, or installs from the registry its .npmrc names) and rustfmt --check (rustup honours the repository's rust-toolchain.toml). Sandboxed backends (Docker, SSH, Modal, …) are unchanged.

Diagnostics that need the project's dependencies (for example unresolved-import warnings) may be less precise until you trust the workspace.

lsp:
trusted_workspaces:
- ~/code/my-app

Supported languages​

LanguageServerAuto-install
Pythonpyright-langservernpm
TypeScript / JavaScript / JSX / TSXtypescript-language-servernpm
Vue@vue/language-servernpm
Sveltesvelte-language-servernpm
Astro@astrojs/language-servernpm
Gogoplsgo install
Rustrust-analyzermanual (rustup)
C / C++clangdmanual (LLVM)
Bash / Zshbash-language-servernpm
YAMLyaml-language-servernpm
Lualua-language-servermanual (GitHub releases)
PHPintelephensenpm
Laravel Blade (.blade.php)laravel-lspmanual (composer)
OCamlocaml-lspmanual (opam)
Dockerfiledockerfile-language-server-nodejsnpm
Terraformterraform-lsmanual
Dartdart language-servermanual (dart sdk)
Haskellhaskell-language-servermanual (ghcup)
Juliajulia + LanguageServer.jlmanual
Clojureclojure-lspmanual
Nixnixdmanual
Zigzlsmanual
Gleamgleam lspmanual (gleam install)
Elixirelixir-lsmanual
Prismaprisma language-servermanual
Kotlinkotlin-language-servermanual
Javajdtlsmanual
PowerShellPowerShellEditorServices (pwsh host)manual (release zip)

For "manual" entries, install the server through whatever toolchain manager makes sense for that language (rustup, ghcup, opam, brew, …). Tutou auto-detects the binary on PATH or in <TUTOU_HOME>/lsp/bin/.

PowerShell​

PowerShellEditorServices isn't a single binary — it's a PowerShell module bundle launched by a pwsh (PowerShell 7+) or powershell host. Setup:

  1. Install PowerShell so pwsh (or Windows powershell) is on PATH.
  2. Download the latest release zip from PowerShellEditorServices releases and extract it.
  3. Point Tutou at the extracted bundle — the directory that contains PowerShellEditorServices/Start-EditorServices.ps1. Either:
    • set lsp.servers.powershell.command: ["/path/to/bundle"] in config.yaml, or
    • extract it to <TUTOU_HOME>/lsp/PowerShellEditorServices, or
    • export PSES_BUNDLE_PATH=/path/to/bundle.

tutou lsp status reports installed once pwsh is found; if the bundle is missing you'll see a one-time warning in the logs with the download link.

Laravel Blade​

.blade.php templates go to laravel-lsp (Blade, Eloquent, Facades) while plain .php files stay with intelephense. Install it once with Composer and make sure the binary is on PATH (or pin it with lsp.servers.laravel-lsp.command):

composer global require laravel/lsp
export PATH="$HOME/.config/composer/vendor/bin:$PATH"

Tutou launches it as laravel-lsp lsp (stdio). There is no auto-install recipe; tutou lsp status shows manual-only until the binary is found.

A few servers are installed alongside a peer dependency that npm won't auto-pull. typescript-language-server and @vue/language-server require the typescript SDK importable from the same node_modules tree — Tutou installs typescript@6 (the last JavaScript-based line; TypeScript 7 is the Go port and ships no tsserver.js) together with the server when you run tutou lsp install typescript / tutou lsp install vue-language-server or auto-install fires on first use.

Vue is pinned to @vue/language-server@2, started with vue.hybridMode: false so it hosts its own TypeScript service. The 3.x line only works behind a client-hosted tsserver tunnel (the VS Code / Neovim setup) that Tutou's generic client does not run, so it never publishes diagnostics. If an earlier Tutou installed 3.x, the log shows a one-time vue-language-server: ... 3.x warning; delete <TUTOU_HOME>/lsp/node_modules/@vue and <TUTOU_HOME>/lsp/bin/vue-language-server*, then run tutou lsp install vue-language-server (the recipe co-installs the TypeScript SDK).

CLI​

tutou lsp status # service state + per-server install status
tutou lsp list # registry, optionally --installed-only
tutou lsp install <id> # eagerly install one server
tutou lsp install-all # try every server with a known recipe
tutou lsp restart # tear down running clients
tutou lsp which <id> # print resolved binary path

tutou lsp status is the best starting point — it shows which languages will get semantic diagnostics today and which need a binary installed.

Configuration​

The defaults work for typical setups; nothing to set if the binaries are on PATH.

# config.yaml
lsp:
# Master toggle. Disabling skips the entire subsystem — no servers
# spawn, no background event loop runs.
enabled: true

# How long to wait for diagnostics after each write.
wait_mode: document # "document" or "full"
# Max seconds to wait for the server on each of the two waits an
# edit makes: the pre-edit baseline snapshot and the post-edit
# re-check. Only *fresh* diagnostics (produced for the post-edit
# content) are ever reported; if the server doesn't finish within
# this budget, the edit reports "no LSP data" rather than stale
# errors from before the edit. Raise this for slow servers on big
# projects (tsserver, rust-analyzer mid-indexing).
wait_timeout: 5.0

# Budget for the FIRST request against a workspace whose server is
# not running yet — spawn, initialize and the server's initial program
# build all happen inside it (tsserver on a 10k-file project can need
# a minute). Once the client is up, wait_timeout applies again.
# 0 = no extra grace (same as wait_timeout).
warmup_timeout: 0

# After a server fails for a workspace (spawn error, or the request
# outran its budget) that (server, root) pair is skipped. 0 = for the
# rest of the process (until `tutou lsp restart`); N = retried after
# N seconds, so one transient stall does not silence a workspace
# forever. Skips are logged once per root at INFO with the retry time.
broken_retry_seconds: 0

# Workspace roots where no language server runs at all — glob
# patterns matched against the resolved project root (~ expanded; a
# bare path also matches everything beneath it). Use it for the one
# huge monorepo whose server cannot finish in budget while every other
# workspace keeps its diagnostics — unlike servers.<id>.disabled,
# which switches the server off everywhere. Must be a list; any other
# shape logs a warning and skips LSP for every workspace until fixed.
exclude_roots: []
# exclude_roots: ["~/work/huge-monorepo", "/srv/checkouts/*/vendor"]

# Directories whose projects a language server may load code from
# (see "Workspace trust" above). ~ expanded; everything under an
# entry counts. The worktree of the directory you launched Tutou in,
# or opened the session in, is always trusted.
trusted_workspaces: []
# trusted_workspaces: ["~/code/my-app"]

# How to handle missing server binaries.
# auto — install via npm/pip/go install into <TUTOU_HOME>/lsp/bin
# manual — only use binaries already on PATH
install_strategy: auto

# Node package manager for the npm-based servers: npm (default), pnpm
# or yarn. Installs still land in <TUTOU_HOME>/lsp/node_modules; a
# manager that is configured but not installed — or a value outside
# npm|pnpm|yarn — skips the install with a warning instead of silently
# using npm, so a pnpm/yarn supply-chain policy (minimumReleaseAge,
# allowBuilds, …) is never bypassed. Yarn Berry (2+): its default PnP
# linker writes no node_modules/.bin, so set `nodeLinker: node-modules`
# in <TUTOU_HOME>/lsp/.yarnrc.yml. pnpm 11 blocks git-hosted transitive
# deps by default (ERR_PNPM_EXOTIC_SUBDEP); @vue/language-server 2.x pulls
# one in, so under pnpm that server is skipped with the pnpm error in the
# log — install it once with npm, or relax block-exotic-subdeps in
# <TUTOU_HOME>/lsp/.npmrc if your policy allows it.
package_manager: npm

# How long an unused language-server client stays alive (seconds).
# Idle servers are shut down automatically and respawned on the next
# relevant file operation. Set to 0 to disable idle reaping and keep
# servers alive for the life of the process. Values below 30s are
# clamped to 30 so a sweep can never reap a client mid-operation.
idle_timeout: 600

# Per-server overrides (all optional).
servers:
pyright:
disabled: false
command: ["/abs/path/to/pyright-langserver", "--stdio"]
env: { PYRIGHT_LOG_LEVEL: "info" }
initialization_options:
python:
analysis:
typeCheckingMode: "strict"
typescript:
disabled: true # skip TS even when its extensions match

Per-server keys​

  • disabled: true — skip this server entirely even when its extensions match a file.
  • command: [bin, ...args] — pin a custom binary path. Bypasses auto-install.
  • env: {KEY: value} — extra env vars passed to the spawned process. Servers and the npm / go install auto-installers start from Tutou' scrubbed child environment (no gateway tokens or provider API keys), so a server that needs one of those gets it only through this key.
  • initialization_options: {...} — merged into the LSP initializationOptions payload sent in the initialize handshake. Server-specific; consult the language server's docs.

Custom servers​

Any lsp.servers key that is not a built-in server id declares your own language server. It needs command and extensions; the other keys are optional. Custom servers are matched before the built-ins, so they can also take over an extension Tutou already handles.

lsp:
servers:
panache:
command: ["panache-lsp", "--stdio"] # PATH lookup or an absolute/~ path
extensions: [".pnch"] # or basenames like "Justfile"
root_markers: ["panache.toml"] # nearest dir with one of these; default: workspace root
language_id: "panache" # didOpen languageId; default: derived from the extension
description: "Panache markdown"
env: { PANACHE_LOG: "warn" } # same optional keys as built-ins
initialization_options: {}

Custom servers are never auto-installed: put the binary on PATH (or give an absolute path) and tutou lsp status lists it as installed. A malformed entry is logged and skipped without affecting the other servers.

Installation locations​

When install_strategy: auto, Tutou installs binaries into <TUTOU_HOME>/lsp/bin/. NPM packages land in <TUTOU_HOME>/lsp/node_modules/ with bin symlinks one level up. Go binaries come from go install with GOBIN pointed at the staging dir.

Nothing is ever installed to /usr/local/, ~/.local/, or any other shared location — the staging dir is fully Tutou-owned and is removed when you reset the profile.

Performance characteristics​

LSP servers are lazy-spawned on first use. Editing a Python file in a project that's never seen .py traffic spawns pyright; the spawn takes 1-3 seconds for most servers (rust-analyzer can take 10+ on a cold project). Subsequent edits in the same workspace re-use the running server.

The LSP layer adds a few milliseconds to clean writes when no diagnostics are emitted. When diagnostics are emitted, the wait budget is wait_timeout seconds — typically the server responds in tens of milliseconds for pyright/tsserver and a few seconds for rust-analyzer mid-indexing. Each edit waits twice (a pre-edit baseline snapshot for the delta, then the post-edit re-check), so a server that never answers costs at most 2 × wait_timeout per edit. The very first request against a workspace also pays the spawn and the server's initial program build; give large projects room with lsp.warmup_timeout (only that first, cold request uses it — the steady-state budget is unchanged) rather than raising wait_timeout, which would let every later edit block for the cold-build duration.

A server that fails for a workspace — spawn error, or a request that outran its budget — marks that (server, root) pair broken and every later request for it is skipped (logged once per root at INFO). By default the pair stays broken until tutou lsp restart or process exit; lsp.broken_retry_seconds: N retries it after N seconds so one transient stall does not cost the workspace its diagnostics for good. A root you never want served — one monorepo whose server cannot finish in any budget — goes in lsp.exclude_roots; other workspaces keep their servers.

Diagnostics are freshness-gated: a result only counts when the server produced it for the content of the current edit (a publishDiagnostics push at/after the change, or a pull request answered after it). Slow servers that haven't re-checked yet result in "no data" for that edit — never in yesterday's errors being re-reported as current.

Servers are kept alive while they're being used and shut down after lsp.idle_timeout seconds (default 600) with no file activity — a long-running gateway that touches many worktrees no longer accumulates one language-server process per workspace forever. A reaped server is respawned automatically on the next relevant file operation. Set idle_timeout: 0 to disable reaping and hold every server's index warm for the life of the process.

Servers are also released when their workspace goes away, even if they are not idle: removing a Tutou-managed worktree (tutou -w session end, Kanban task cleanup, a delegated subagent's pruned worktree) shuts down that tree's language servers before git worktree remove runs, and the periodic sweep shuts down any server whose project root no longer exists on disk (deleted outside Tutou). The sweep is part of the idle reaper, so idle_timeout: 0 also disables deleted-root reaping; the worktree-removal release always runs. A multi-root server only drops the vanished folder and keeps serving its sibling roots.

Servers that support multi-root workspaces (currently pyright) run as a single process per Tutou process: the first Python project spawns it, and every further project root — for example sibling git worktrees edited by parallel subagents — is attached to that same server as an additional workspace folder instead of starting another copy.

Disabling​

Set lsp.enabled: false in config.yaml to disable the entire subsystem. The post-write check falls back to the in-process syntax check (ast.parse for Python, json.loads for JSON, etc.) which ships unchanged from earlier versions.

To disable a single language without disabling the whole layer:

lsp:
servers:
rust-analyzer:
disabled: true

Troubleshooting​

tutou lsp status shows a server as "missing"

The binary isn't on PATH and isn't in <TUTOU_HOME>/lsp/bin/. Run tutou lsp install <server_id> to attempt an auto-install, or install the binary manually through the language's normal toolchain.

Backend warnings section in tutou lsp status

Some servers ship as thin wrappers around an external CLI for actual diagnostics — they spawn cleanly and accept requests but never emit errors when the sidecar binary is missing. The most common case is bash-language-server, which delegates diagnostics to shellcheck. When tutou lsp status shows a Backend warnings section, install the named tool through your OS package manager:

apt install shellcheck # Debian / Ubuntu
brew install shellcheck # macOS
scoop install shellcheck # Windows

The same warning is logged once at server spawn time in ~/.tutou/logs/agent.log.

Server starts but never returns diagnostics

Check ~/.tutou/logs/agent.log for [agent.lsp.client] entries — both stderr from the language server and protocol errors land there. Some servers (rust-analyzer especially) need to finish a project-wide index before they emit per-file diagnostics; the first edit after server start may complete with no diagnostics, with subsequent edits picking them up.

Server crashed

A crashed server is added to the broken-set and won't be retried for the rest of the session. Run tutou lsp restart to clear the set; the next edit re-spawns.

Editing a file outside any git repo

By design, LSP only runs inside a git repository. If the project isn't yet initialized, run git init to enable LSP diagnostics. Otherwise the in-process syntax-only fallback applies.