Skip to main content
Glama
README.md
# LSPSteer

[![CI](https://github.com/haristariq/lspsteer/actions/workflows/ci.yml/badge.svg)](https://github.com/haristariq/lspsteer/actions/workflows/ci.yml)

**Compiler-in-the-loop steering for coding agents - Cursor *and* Claude Code.**
LSPSteer runs your workspace's real language server on each edit, extracts the
diagnostics scoped to the lines that changed *and the in-scope type signatures
needed to fix them*, and feeds that back to the coding agent - so generation is
constrained by program analysis instead of corrected by post-hoc lint.

It plugs into both editors through the same warm daemon: an **MCP tool**
(`typecheck_scope`) the agent calls after editing, plus optional **hooks** that
enforce a clean type-check before the agent ends its turn. One command wires it
up: `lspsteer init`.

> An agent's edit can be perfectly well-formed and still wrong: it passes a
> `number` where a `string` id is required, or reads `.balance` off a
> `Promise<User>`. Grammar- and syntax-level guidance never catches this.
> Studies of LLM compilation failures find the overwhelming majority are
> **type-check** errors, not syntax errors - so the dominant failure class is
> exactly the one a type checker sees and a linter doesn't.

**Honest result up front:** in a paired, deterministically-scored A/B (150 runs,
`eval/`), compiler-in-the-loop feedback beats no feedback on first-attempt
correctness, but on small readable repos LSPSteer matches an agent that just runs
`tsc` itself. The edge it is designed for (scoping when `tsc` emits hundreds of
errors, resolving types that are expensive to read) should show on large repos,
which the current suite does not cover. Full numbers below and in
[`eval/FINDINGS.md`](eval/FINDINGS.md).

---

## What it looks like

The core mechanism is the **steering note**: after an edit, LSPSteer returns the
diagnostics scoped to the changed lines plus the in-scope type signatures, so the
agent works from the resolved types instead of guessing them.

```
LSPSteer found 2 errors in your edit to payments.ts. Fix before finishing.

Errors:
- payments.ts:4:25 [error ts2345] Argument of type 'number' is not assignable
    to parameter of type 'string'.
    > const user = findUser(42);
- payments.ts:5:15 [error ts2339] Property 'balance' does not exist on type
    'Promise<User>'.
    > return user.balance * 1.1;

Relevant types in scope (use these to fix correctly):
- function chargeUser
- findUser: function findUser(id: string): Promise<User>
```

The difference from "just run `tsc`": errors are scoped to the edit, and the
agent is handed the **resolved types it is actually working with**
(`findUser(id: string): Promise<User>`) rather than a wall of project-wide output.

<details>
<summary>Illustrative full loop (<code>npm run demo</code>): broken edit, steered, fixed</summary>

This shows the steering note inside an end-to-end flow on the included fixture.
It is an illustration of the mechanism, not a benchmark; for measured impact see
the result line above.

```
STEP 1 - Agent writes payments.ts (compiles? no. syntactically valid? yes)
  export function chargeUser(): number {
    const user = findUser(42);
    return user.balance * 1.1;
  }

STEP 2 - LSPSteer analyzes the edit (cold: daemon boot + tsserver load)
  [the steering note shown above]

STEP 3 - Re-analyze the same edit (warm: server already loaded, sub-second)
  [same note, now in < 1s]

STEP 4 - Agent applies the guidance
  export async function chargeUser(id: string): Promise<number> {
    const user = await findUser(id);
    return user.isActive ? 10 : 0;
  }
  LSPSteer: payments.ts is type-clean.
```

</details>

## Why a daemon

A cold `tsserver`/Pyright/rust-analyzer load is several seconds; warm edits are
sub-second. LSPSteer keeps language servers **warm in a long-lived daemon**, one
per project, behind a Unix-socket IPC. The hooks and the MCP server are thin
clients. Measured on the fixture:

| | latency |
|---|---|
| cold load (one-time per project) | ~5-12s |
| **warm analysis (every edit after)** | **< 1s** |

## How it plugs into your agent

Two layers; the first is the robust default. Both work identically in Cursor and
Claude Code - only the config file locations and the hook event names differ, and
`lspsteer init` writes the right ones for you.

1. **MCP tool + rule (recommended).** LSPSteer exposes a `typecheck_scope` MCP
   tool. A bundled rule (Cursor's `.cursor/rules/lspsteer.mdc`, or a block in
   `CLAUDE.md` for Claude Code) tells the agent to call it after editing a file
   and fix any reported errors before finishing. This rides stable,
   well-specified MCP I/O.
2. **Hooks (optional enforcement).** An edit hook (Cursor `afterFileEdit`,
   Claude Code `PostToolUse` on `Edit|Write|MultiEdit`) records edited files and
   pre-warms the server; a stop hook (Cursor `stop`, Claude Code `Stop`)
   re-checks them when the agent tries to end the turn and hands back a steering
   note if errors remain. The two harnesses expect different Stop-hook output
   shapes; LSPSteer detects which one it's running under and emits the right one
   (`{"decision":"block","reason":…}` for Claude Code), failing open on any error.

### MCP tools

- `typecheck_scope(file, changedRanges?)` - diagnostics scoped to the changed
  lines + in-scope type signatures. The steering tool.
- `get_diagnostics(file)` - every diagnostic for a file (whole-file check).
- `lspsteer_status(workspaceRoot?)` - which servers are warm.

## Install

LSPSteer is a TypeScript/Node project. From a clone:

```bash
npm install
npm run build
node dist/cli.js doctor .   # verifies the language server resolves & starts
```

Then wire it into any project with a single command:

```bash
# from inside the target project (writes config for both editors by default):
lspsteer init
# or pick one, and/or pass a target directory:
lspsteer init --cursor /path/to/project
lspsteer init --claude /path/to/project
```

`init` writes the MCP registration, the rule, and the (optional) hooks with
absolute paths filled in, **merging** into any existing config rather than
overwriting it, and is safe to re-run (idempotent). It targets:

| | Cursor | Claude Code |
|---|---|---|
| MCP | `.cursor/mcp.json` | `.mcp.json` |
| Rule | `.cursor/rules/lspsteer.mdc` | block appended to `CLAUDE.md` |
| Hooks | `.cursor/hooks.json` | `.claude/settings.json` |

Reload your editor afterward. To wire it by hand instead, see
[`cursor/README.md`](cursor/README.md) and [`claude/README.md`](claude/README.md).

### CLI

```
lspsteer init [dir]      Wire LSPSteer into a project (--cursor | --claude | --both)
lspsteer daemon [root]   Run the warm-LSP daemon in the foreground
lspsteer mcp             Run the MCP server on stdio (what the editor launches)
lspsteer doctor [root]   Check that language servers resolve and start
lspsteer status [root]   List warm language servers
```

## Language support

TypeScript/JavaScript works today (via `typescript-language-server`, which uses
your project's own TypeScript). Support is **pluggable**: a language is one
`LanguageAdapter` entry (how to launch the server + which files it owns).
Python (Pyright) and Rust (rust-analyzer) adapters are stubbed in
`src/lsp/adapters.ts` - install the server and the rest of the pipeline
(diff-scoping, scope resolution, daemon, hooks, MCP) works unchanged.

## How it works

```
           Cursor / Claude Code agent
                     │  (edits a file)
        ┌────────────┴─────────────┐
        │                          │
    edit hook                 typecheck_scope (MCP)
   (record + pre-warm)        (scoped diagnostics + types)
   afterFileEdit / PostToolUse
        │                          │
        └──────────┐    ┌──────────┘
                   ▼    ▼
            LSPSteer daemon  ── warm pool, one server per project
                   │                (Unix-socket IPC)
                   ▼
            LSP client  ──►  typescript-language-server ──► tsserver
              didOpen/didChange · publishDiagnostics · hover · documentSymbol
```

The steering note is built by (1) syncing the file to the warm server and
collecting the diagnostics it publishes, (2) keeping only those overlapping the
changed lines (plus a little context), and (3) resolving the in-scope types -
the enclosing function's signature plus the resolved types of the identifiers in
the changed region (via `hover`/`documentSymbol`). See
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).

## Does it work?

`npm test` runs the hermetic component tests (LSP client, daemon IPC, MCP
server). `npm run e2e` drives a live headless Claude Code agent and shows the
steering loop end to end: with the agent denied `Read`, `typecheck_scope` is its
only source of type info, and it goes from a `TS2339` first guess to type-clean.

That demo isolates the mechanism but overstates the payoff. A controlled,
paired A/B (`eval/`, 150 runs, scored by `tsc --noEmit` + held-out behavioral
tests) gives the honest picture:

- A compiler-in-the-loop signal improves first-attempt correctness over no tool
  (0% to ~32-44%, McNemar p=0.013).
- On small readable repos, LSPSteer shows no measurable advantage over just
  running `tsc` (first-diff 32% vs 36%, p=1.0), and final correctness is at
  ceiling because a capable agent self-corrects by reading.
- The tool adds ~1.4 turns and ~$0.015 per task.

So today the defensible claim is convenience and warmth, not a correctness win
over the compiler. The advantage LSPSteer is designed for (diff-scoping when
`tsc` dumps hundreds of errors, resolved types when reading is expensive) should
appear on large repos, which the current suite does not cover. Full method and
numbers: [`eval/README.md`](eval/README.md), [`eval/FINDINGS.md`](eval/FINDINGS.md).
Test ladder: [`docs/TESTING.md`](docs/TESTING.md).

## Background and inspiration

LSPSteer operationalizes, as a Cursor capability, a line of recent work on
feeding program-analysis signals back into LLM code generation:

- **Type-Constrained Code Generation with Language Models** - arXiv 2504.09246
  (PLDI 2025). Constraining decoding to the type system more than halves
  compilation errors and improves repair of non-compiling code. Background for
  why type signals are the right control signal.
- The observation that **type-check failures dominate** LLM compilation errors
  (with syntax errors a small minority for TypeScript) - Mündler et al., PLDI
  2025 - motivates targeting types rather than grammar.
- **Lanser-CLI** (arXiv 2510.22907) and **LSPRAG** (arXiv 2510.22210) - using
  the language server as a process-reward / retrieval source for agents. These
  are the closest prior work. LSPSteer differs on what it ships and where: it is
  not a CLI the agent shells out to or a retrieval index, but an always-on warm
  daemon wired into Cursor and Claude Code through their native MCP + hooks
  surfaces, returning a single diff-scoped steering note at edit time.
- Adjacent: **SAILOR** (arXiv 2604.06506, 2026), static analysis guiding LLM
  symbolic execution, and **Agentic Code Reasoning** (arXiv 2603.01896, 2026) -
  related uses of analysis signals in LLM reasoning, not editor-time type
  steering.

LSPSteer's contribution is not a new model or decoding algorithm; it is the
*editor-time plumbing* - a warm, diff-scoped, type-resolving steering loop wired
into the MCP + hooks surfaces of Cursor and Claude Code - that the above research
implies but that no editor tool currently provides off the shelf.

## Status

Working end-to-end and verified: LSP client, diff-scoped analysis + type
resolution, warm daemon with IPC, MCP server, hooks (Cursor **and** Claude Code),
the `init` installer, and the CLI all run today - `npm test` exercises the LSP
client, the daemon IPC path, and the MCP server against the bundled fixture.
TypeScript is the proven language; Python/Rust adapters are stubs. The
Stop-hook output contract is isolated in one harness-aware function
(`emitDecision`) because the shape differs between Claude Code and Cursor (and
has varied across Cursor versions).

## License

MIT.