Plugins · CPO
Language servers
Rules backed by real language servers: type errors a write introduces, what a call resolves to, deprecations.
CPO never installs a language server. It runs those of the official Claude Code LSP plugins from the PATH,
and tells the user how to install a missing one. ccpp cpo lsp status shows which are installed.
lsp-diagnostics
---
description: No new type errors
on: [file.write]
when:
builtin: lsp-diagnostics
with: { severity: error } # onlyNew: true by default
where: "!facts.introduced.exists(d, d.code == 6133)" # ignore "declared but never read"
---
This edit introduces {{ facts.count }} error(s) in {{ file.relative }}:
{{ facts.errors }}On file.write, the server sees the file at its current text, then at the text about to be written (in memory:
nothing touches the disk). The policy holds on the diagnostics the write introduces. A line shift keeps an
error; a second identical error in the same function is new.
Across files, at the end of a turn
With since, the check covers every file changed since a snapshot, plus up to importers (10) files that
reference their top-level symbols. A renamed export that breaks its importers is caught, which a per-write check
cannot see.
on: [turn.prompt]
when: { cel: "git.repo" }
then: [{ snapshot: { as: state.turn.start } }]---
description: Leave no type errors behind
on: [turn.end]
when: { builtin: lsp-diagnostics, with: { since: state.turn.start } }
---
You left {{ facts.count }} type error(s): {{ facts.errors }}lsp-query
A tree-sitter query selects nodes of the content being written, then the server answers ask at each selected
node (at most limit, 10):
ask | Answer |
|---|---|
definition | { path, relative, line, column } |
type | the hover's signature and its valueType |
references | a count |
deprecated | a deprecated tag or @deprecated in the hover |
on: [file.write]
paths: ["src/domain/**"]
when:
builtin: lsp-query
with:
query: '(call_expression function: [(identifier) @match (member_expression property: (_) @match)])'
language: [typescript, tsx]
ask: [definition, deprecated]
onlyNew: true
where: "facts.introduced.exists(f, f.deprecated == true || (f.definition != null && glob(f.definition.relative, 'src/infra/**')))"Unknown values are null: guard them in where.
Never blocking on the server
A policy step has 5 seconds, and an error or timeout blocks. So an lsp-* check gets a 4-second budget
(CPO_LSP_BUDGET_MS) and turns everything it cannot judge into a skip: a missing server, one still loading the
project, a late answer. Both builtins cost 60, so they run after cheaper policies; in an all, put them last.
Effects
on: [session.start]
then: [lsp-warm] # starts the servers of the project's languageslsp-warm never blocks. lsp-release, on session.end, tells the daemon a session ended.
The daemon
One daemon per project keeps the servers alive between hook calls, shared by parallel sessions. It starts with
the first lsp-* policy, stops with its last session or after 30 idle minutes (CPO_LSP_IDLE_MS).
| Command or variable | |
|---|---|
ccpp cpo lsp status | Installed servers, how to install the others, the daemon's state. |
ccpp cpo lsp stop | Stops the project's daemon. |
CPO_LSP_COMMAND_<ID> | A JSON array replacing a server's command line. |
With TypeScript 7 or later in the project's node_modules, CPO runs its tsc --lsp --stdio and needs nothing
else installed.