@ian-pascoe/pi-lsp
Configured language-server tools and post-edit diagnostics for Pi
Package details
Install @ian-pascoe/pi-lsp from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@ian-pascoe/pi-lsp- Package
@ian-pascoe/pi-lsp- Version
0.14.2- Published
- Oct 15, 2026
- Downloads
- 2,387/mo · 1,251/wk
- Author
- ianpascoe_
- License
- MIT
- Types
- extension, skill
- Size
- 526.7 KB
- Dependencies
- 3 dependencies · 3 peers
Pi manifest JSON
{
"skills": [
"./skills"
],
"extensions": [
"./src/index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@ian-pascoe/pi-lsp
Configured language-server tools and post-edit diagnostics for Pi.
Install
pi install npm:@ian-pascoe/pi-lsp
# or
pi install git:github.com/ian-pascoe/pi-extensions
For a local checkout, run pi -e ./packages/pi-lsp/src/index.ts.
Install every language-server executable separately. Pi LSP contains no server catalog or installer. It launches configured commands over stdio with the Pi process's environment and permissions.
Settings
Pi LSP reads only the lsp key from Pi's global settings.json and trusted project
.pi/settings.json:
{
"lsp": {
"includeHintDiagnostics": false,
"timeouts": {
"initializeMs": 45000,
"requestMs": 3000,
"workspaceRequestMs": 15000,
"diagnosticsMs": 3000,
"shutdownMs": 5000
},
"servers": {
"typescript": {
"command": "pnpm",
"args": ["exec", "tsc", "--lsp", "--stdio"],
"languages": [
{
"extensions": [".ts", ".mts", ".cts"],
"languageId": "typescript"
},
{
"extensions": [".tsx"],
"languageId": "typescriptreact"
}
],
"requireRootMarker": true,
"rootMarkers": ["tsconfig.json", "package.json", ".git"],
"initializationOptions": {},
"settings": {},
"environment": {}
}
}
}
}
Every field is optional except a Server Definition's non-empty command and languages, even
when that definition is disabled. Each
language needs a non-empty languageId and at least one extension or exact filename. Extensions
include their leading period. rootMarkers are basename glob patterns; the nearest matching
ancestor becomes the server root and Pi's working directory is the fallback. Set
requireRootMarker to true to exclude the server for files without any matching ancestor; it
defaults to false. A required empty rootMarkers list is invalid. Explicit requests naming an
otherwise compatible excluded server report that its required root marker was not found. A server
without rootMarkers or workspaceRootMarkers always uses the working directory as its root.
Routing reads a file's ancestor directories only when an enabled server that handles its language
has either kind of marker.
Set workspaceRootMarkers to let one server cover a monorepo. They are basename glob patterns
such as pnpm-workspace.yaml or .git; when set, the nearest ancestor that contains one becomes
the server root, so files in every package of a workspace share one server and
lsp_find_references and lsp_rename can cross package boundaries. The search never selects
your home directory, any directory above it, or the filesystem root, unless Pi's working
directory is at or above it. Without a matching ancestor, the nearest rootMarkers ancestor and
then the working directory are used as before. requireRootMarker still checks only
rootMarkers, so a workspace marker alone does not make a server apply. An empty list is the
same as leaving it unset. For example, add
"workspaceRootMarkers": ["pnpm-workspace.yaml"] to the TypeScript server above.
A server searches only the code it has loaded: the TypeScript server loads a package's
tsconfig.json project when a file in that package is opened, so references in a package where
the server has no open file can be missing. Before lsp_find_references and lsp_rename query a
workspace root, Pi LSP therefore opens one representative file in each package of it (directories
holding one of the server's rootMarkers) where the server has no open file yet. The file is the
shallowest source file the server handles, preferring src and avoiding declaration, test, and
configuration files. Warm-up opens at most 20 files and spends at most 10 seconds, and never fails
the request; a package it cannot open, or does not reach, is not loaded. Those packages are
disclosed as one line that counts them and names up to three, for example
typescript has not loaded 7 packages (packages/b, packages/c, packages/d, +4 more) under /work/repo; references there may be missing. Run any LSP tool on a file in each missing package, then retry. (pi-lsp troubleshooting Skill: <path>). Pi LSP opens files it was not asked
about only for these two operations, and only in a workspace root. A package stops being named once any LSP tool has opened one of its files with that server
in the session; the server keeps at most 100 files open, so a package whose files were all closed
again is named again. The packages are found by the same bounded search as other workspace roots
(below); when it stops early, the warning says so instead of claiming other roots outside the
workspace. A server without rootMarkers gets no package warning. One server for a whole
workspace also uses more memory than one per package.
includeHintDiagnostics (default false) controls whether Post-edit Diagnostics include
hint-severity findings; a project value overrides the global one. Global and project timeouts
merge by field. workspaceRequestMs is the budget for workspace-wide requests (references,
implementation, rename, workspace symbols, incoming and outgoing calls), which can wait on a server
that is still loading projects; it is never lower than requestMs, which bounds every other request. A project server replaces the complete global server with the same ID; set a project server to null to remove it. initializationOptions is sent only
during initialization. settings is used for workspace/didChangeConfiguration and
workspace/configuration. Environment strings override process.env; null removes a variable.
Invalid server definitions, timeout fields, and includeHintDiagnostics are quarantined
individually and remain visible through status; unrelated valid settings continue to work. An invalid project server replacement
still shadows the global definition. Untrusted project settings are ignored.
Pi's /reload reloads configuration. Servers start lazily on first use and live for one Pi session.
After a process or protocol failure, recovery requires stopping the affected Instance, disabling
then enabling its Definition, using the existing restart tool operation, or /reload.
/lsp command
Use /lsp to inspect server status and choose an action. The picker displays known workspace
roots and the effective enablement scope; opening it never starts a server. Text shortcuts are:
/lsp stop typescript
/lsp stop typescript "packages/my app"
/lsp disable typescript
/lsp enable typescript
/lsp disable typescript --project
/lsp enable typescript --global
- Stop ends one known Server Instance and clears its failure state. The next matching request can start it again. Omit the root when only one is known; otherwise choose a root in the picker or supply its path, relative to Pi's working directory or absolute.
- Disable prevents automatic and explicit startup of the entire Server Definition, stops all its Instances in this session, and clears their failed runtime state.
- Enable permits lazy startup without launching a process.
Unflagged enable/disable choices are custom session entries. They survive /reload and saved-session
resume, but follow the selected branch: navigating before a choice rolls it back, and forks inherit
choices on their selected ancestry. A newly created session starts without session overrides. Pi
only flushes entries in a brand-new session after its first assistant message; ephemeral sessions
are not persisted.
--project and --global write only the chosen settings document's lsp.enablement map, preserving
Server Definitions and unrelated settings. Project writes require a trusted project. For example,
this project setting disables an inherited global Definition without copying it:
{ "lsp": { "enablement": { "typescript": false } } }
Enablement resolves independently of Server Definition replacement, by server ID:
session override → project setting → global setting → enabled by default. Explicit true
can override a lower scope's false. Scoped commands do not change session overrides and report
when a higher-priority choice masks their effect. They update eligibility immediately in the current
session; other sessions read the settings on startup or /reload. Changes to commands, languages,
and other definition fields still require /reload.
There are no /lsp start, /lsp restart, or /lsp inherit subcommands. Use explicit enable/disable
choices to manage overrides. The agent-facing LSP tools remain available, but cannot start a
disabled server. Without server_id, they skip disabled servers when another matching server is
enabled; when every server for the file's language is disabled, the error names them and points to
/lsp enable <id>.
LSP tools
The extension registers one Pi tool per operation, named lsp_<operation> and grouped under the
lsp tool namespace. Each tool's parameters contain only that operation's fields. The core tools
are declared to the model (lsp_status included, since the troubleshooting Skill starts from it); the others use Pi's codemode exposure:
| Exposure | Tools |
|---|---|
Declared (direct) |
lsp_status, lsp_diagnostics, lsp_hover, lsp_goto_definition, lsp_find_references, lsp_document_symbols, lsp_workspace_symbols, lsp_rename, lsp_code_actions, lsp_apply |
Script-callable (codemode) |
lsp_capabilities, lsp_restart, lsp_workspace_diagnostics, lsp_completion, lsp_signature_help, lsp_declaration, lsp_goto_type_definition, lsp_goto_implementation, lsp_document_highlights, lsp_document_links, lsp_call_hierarchy, lsp_incoming_calls, lsp_outgoing_calls, lsp_type_hierarchy, lsp_supertypes, lsp_subtypes, lsp_selection_ranges, lsp_folding_ranges, lsp_code_lenses, lsp_inlay_hints, lsp_document_colors, lsp_format_document, lsp_format_range, lsp_format_on_type, lsp_prepare_rename |
Script-callable tools are not declared to the model. They are reachable in three ways:
- With Pi's
codemodetool active, scripts call them astools.lsp_capabilities({ server_id, file_path }), and thecodemodedescription lists them with their declarations under thelspnamespace. - With Pi's
tool_searchtool active, the model can find one and declare it for later turns. - Any tool can be declared by name, like other inactive extension tools: add it to
defaultTools(for example"defaultTools": ["+lsp_workspace_diagnostics"]) or name it in--tools.
codemode and tool_search are off by default; enable them with "defaultTools": ["+codemode"]
or ["+tool_search"]. Without one of them, only the declared tools are available unless you add
others by name.
File tools use file_path. Position tools also use one-based line and character; characters
count Unicode code points, regardless of the server's negotiated UTF-8, UTF-16, or UTF-32
encoding. Paths may start with @. lsp_selection_ranges accepts a positions array.
lsp_find_references accepts include_declaration (default true).
Result positions are one-based Unicode code points too. Location results (lsp_find_references,
lsp_goto_definition, lsp_goto_type_definition, lsp_goto_implementation, lsp_declaration,
and lsp_document_highlights) reach the model as one line per location:
Query position: src/a.ts:1:14 ("value")
src/a.ts:1:14 export const value = 1;
src/b.ts:3:17 console.log(value);
Paths are relative to Pi's working directory, or absolute outside it, and each line ends with the
trimmed source line. Document highlights name their kind (read, write, or text) after the
position. Results are grouped under each server ID only when more than one server answered, and
server failures follow as Warning: lines.
Each result position is converted against the text of the file it lies in; the queried file uses
the text the server was synced with. A non-file: URI such as jdt:// or deno: is shown as is.
Positions in a file without readable text (a non-file: URI, a failed read, or invalid UTF-8) are
approximated by adding 1 to the line and character, and a warning names those files: their lines
are exact, but columns may be off after non-ASCII text.
A character past the end of its line is clamped to the line end, as the LSP specification says,
without a warning: servers send such a character, such as 2147483647, to mean the end of the line.
A file that changed on disk after the server read it can also disagree in other ways: a line past
the end of the file keeps the server's position (adding 1 to the line and character), and a
position inside a Unicode character snaps to the start of that character. A warning names those
files, because their positions may be wrong (the file changed since the server read it, or the
server sent an invalid position), and the server's other positions are unaffected. Positions that
still fit the changed text but now point somewhere else cannot be detected.
Symbol, hierarchy, and range results use the same paths and positions, with symbol kinds named
(class, function, variable, …) instead of numbered:
lsp_document_symbolsandlsp_workspace_symbolslistname (kind) path:line:collines. Document symbols form an indented outline and end with the symbol's detail; workspace symbols end within <container>. By default (depth: 1) the outline lists top-level declarations and the members of classes, interfaces, enums, namespaces, modules, packages, objects, and structs (rust-analyzer reports animplblock as an object), without the locals, return-object properties, and callbacks inside function, method, and variable bodies. A largerdepthadds one level inside those bodies per step, anddepth: "all"lists the full tree. Unlessdepthis"all", the outline also leaves out import bindings, which servers report as top-level variables: when the server supportstextDocument/foldingRange, one extra request finds itsimportsranges, and a top-level symbol lying entirely inside one is dropped. A server without folding ranges, one that reports noimportsrange, or a failed folding request leaves the imports listed; TypeScript reports noimportsrange for a single import or a lone import after a statement, so those stay listed. Final lines count the nested symbols and the import bindings left out, and the structured result'somittedholds their sum, withomitted_importsholding the import bindings. A type alias appears as(class), because that is how the TypeScript server reports it.lsp_call_hierarchy,lsp_type_hierarchy,lsp_supertypes, andlsp_subtypeslistname (kind) path:line:collines.lsp_incoming_callsandlsp_outgoing_callsindent each call site below the caller or callee aspath:line:col <source line>.lsp_selection_rangeslistsline:col-line:colranges from innermost to outermost, under aline:col:heading per requested position when there are several.lsp_folding_rangeslistsstartLine-endLine kind <first source line>.
Greeter (class) src/a.ts:1:14
greet (method) src/a.ts:2:3 (): string
helper (function, deprecated) src/a.ts:6:17
lsp_completion and lsp_workspace_symbols return at most limit items per server (default 50) and state how many more matched. lsp_completion keeps items whose filter text starts with
prefix, ignoring case, ordered by the server's sort text; prefix defaults to the identifier
before the position, and "" keeps every item. lsp_workspace_symbols keeps the server's order.
Only kept items are resolved. Completions read as label (kind) detail, without the
server-private data that only their resolve request uses:
Completions starting with "lo":
log (method) (method) Console.log(...data: any[]): void
logger (variable) ./logger
12 more omitted; raise limit or refine the prefix to see them.
Scripts receive each server's bounded response with its prefix and the count of omitted
matching items.
Diagnostics, hover, status, code actions, and apply results are compact text too:
lsp_diagnosticsandlsp_workspace_diagnosticslist onepath:line:col-end severity source(code): messageline per LSP Diagnostic. The range end is5:28-52on the same line or5:28-6:3across lines, and is left out when it equals the start, so two findings that start at the same position stay distinct. A queried file without any readspath: no diagnostics. A server that publishes nothing within the diagnostics timeout readspath: no diagnostics published by <server> within <wait> (not a failure; ...): servers that only push diagnostics, such as marksman, stay silent for a clean file, so the file may be clean. The structured value keepsstatus: "timeout"withwaitedMs,pushOnly, andremembered; a pull-capable server's timeout reads as a stalled request (no diagnostics received from <server>) instead. For a push-only server (no document pull), that silence is remembered per document version: a repeat query of the unchanged file answers at once (remembered: true, readingfor this unchanged file) instead of waiting again. A later push for the file, any edit to it, closing it, or a Server Instance restart forgets it; a pull-capable server's timeout is never remembered. Workspace diagnostics start with the server's coverage message, if any, and count clean files on oneN files: no diagnosticsline. A malformed diagnostic is shown as JSON on its own line.lsp_hovershows only the hover's markdown or plaintext contents, under theQuery position:line. The client advertises markdown support for hover, completion, and signature-help documentation, so TypeScript 7'stsc --lspfences the signature and sets it apart from its documentation; servers that only answer plaintext render as sent.lsp_statuslists oneserver_id state [root] language(extensions,...) [error: ...]line per Server Instance (running, starting, unavailable, or stopped) and per Disabled Server Definition. One+N configured, not started (pass all: true to list)line counts the other Server Definitions, followed by settings warnings. Passall: trueto list every configured Server Definition. Its structured result applies the same filter, reports the omitted count asnot_started, and lists everything withall: true.lsp_code_actionslists each action astitle (kind): preview <preview_id>with the preview's diff indented below, or says why it cannot be applied.lsp_applylists the files it modified, created, deleted, or renamed.
src/a.ts:3:7 error ts(2304): Cannot find name 'valeu'.
src/a.ts:5:1 warning oxlint(eslint(no-unused-vars)): Variable 'x' is declared but never used.
Other reads show the server's response as compact JSON, as does a response of an unexpected shape.
Every position tool's result starts by naming the queried position and the token there, so an
off-by-one position is visible: Query position: src/a.ts:1:14 ("value"). On whitespace or past
the line end it shows the trimmed line instead ((no token; line: "...")). When no server found
anything, the result says so for that position instead, for example
No call hierarchy item at src/todo.ts:61:18 ("readonly"). The hierarchy follow-ups
(lsp_incoming_calls, lsp_outgoing_calls, lsp_supertypes, lsp_subtypes) distinguish a
position without a hierarchy item from an item without calls or types.
Pi LSP starts one server per workspace root, so lsp_find_references and lsp_rename search
only the workspace root of the queried file. In a monorepo where every package has a
package.json, renaming a helper exported by one package finds no importers in the others,
unless the server sets workspaceRootMarkers (see Settings). Both
tools name the root they searched (Searched typescript workspace root: packages/a). When the same
server has other roots, they add a one-line warning that counts them and names up to three, for
example typescript searched only packages/a; 5 other typescript roots exist (packages/b, packages/c, packages/d, +2 more), so importers there may be missed. Query a file there or search for importers. (pi-lsp troubleshooting Skill: <path>). The search stops after it finds six other
roots, and the warning then says at least 6; a workspace root's search counts every root it
reaches. Both warnings end with the path of the pi-lsp
troubleshooting Skill, and only a search that really left directories unchecked adds
discovery stopped early. Those roots are its running or known servers and the roots of directories
that contain one of its root or workspace root markers; a package inside the searched workspace root
is not another root. They are found by searching down from the outermost ancestor of the searched
root that contains either kind of marker (or Pi's working directory, when that is higher). Pi can
therefore start inside a package and still find sibling packages under the shared repository root.
The search starts no higher than that directory, and never at your home directory or above it,
unless Pi's working directory is at or above it, so a stray marker file in ~ does not widen the
scan. Symbolic links, hidden directories, and node_modules are skipped. Discovery checks at most
4,096 directories; when it stops early, the warning says other roots may exist, unless every
unchecked directory is inside a searched workspace root, which only the unloaded-package warning
then mentions. The same search finds a workspace root's packages. The rename warning starts the
preview summary, so it is visible before lsp_apply; scripts receive it in warnings and the
searched root in root_path.
lsp_workspace_symbols requires query and a root-anchor file_path, and lsp_workspace_diagnostics
a root-anchor file_path. lsp_capabilities and lsp_restart require server_id and a root-anchor
file_path. All other reads, including lsp_workspace_diagnostics, and lsp_code_actions query
every matching capable server unless narrowed by server_id; successful responses remain visible
when another server fails. Automatic reads omit matching
incapable servers and fail once if none are capable; explicitly selecting an incapable server
reports that the operation is unsupported. lsp_rename and the lsp_format_* tools may omit
server_id only when exactly one matching capable server exists.
lsp_status lists each Server Definition's languages with the file extensions and filenames each
handles (a map from language ID in its structured result). The routing of Server Definitions that
have not started shows with all: true, in the structured result as in the text.
lsp_workspace_diagnostics uses the server's workspace diagnostics pull when it has one, and
otherwise the diagnostics it has pushed for files opened in this session. A server that answers only
document pulls, such as the TypeScript server, publishes no workspace diagnostics; while its push
cache covers no file open in this session, its value is { status: "unsupported", message }, pointing to lsp_diagnostics, rather than an empty result.
Such a server is not omitted as incapable: without server_id, every matching server returns its
own result.
A result from pushed diagnostics carries a message too: it says how many files the push cache
covers, counting files with no diagnostics, and points to lsp_diagnostics for other files, so an
empty result is not read as a clean workspace.
Formatting requires tab_size and insert_spaces. It optionally accepts
trim_trailing_whitespace, insert_final_newline, and trim_final_newlines. Range formatting also
requires a range; on-type formatting requires a position and trigger_character. lsp_rename
requires new_name. A preview whose edits change nothing reports No changes and an empty Mutation
Manifest. lsp_code_actions accepts a range and optional only_kinds filters. A kind matches
itself and its dot-separated sub-kinds (quickfix matches quickfix.import, not quickfixes). The
server is asked to filter, and actions of other kinds, actions with no kind, and plain commands are
dropped even when it ignores the request. It lists the actions of every capable server, each naming
its server_id, with failing servers as warnings. It sends each server's current LSP Diagnostics
that overlap the range, so diagnostic-dependent quick fixes such as adding a missing import are
offered. It uses diagnostics the server has already reported for the file's current contents,
otherwise waits for them within the diagnostics timeout; unavailable diagnostics never prevent the
request. An action whose edit fails Workspace Edit Preview validation (for example, it targets a
missing file) is listed with applicable: false and an error; the server's other actions and
their previews are unaffected. Other failures, such as an unreadable file, still fail the server.
The shared rules reach the model as one system-prompt guideline, which Pi adds once while any
direct LSP tool is declared; the script-callable tools do not repeat it in the codemode
description. The rules also head the ## lsp section of that description, so they stay visible
once with codemode.mode: "only", where declarations are hidden. Where the budget cannot list every
tool, cheaper declarations come first and registration order breaks ties.
Migrating from the single lsp tool
Releases before 0.5.0 registered one lsp tool with an operation argument. It was removed
without an alias:
- Call
lsp_<operation>with the same arguments minusoperation:{"operation":"hover", ...}becomeslsp_hoverwith{ ... }, and{"operation":"apply","preview_id":"..."}becomeslsp_applywith{"preview_id":"..."}. - Settings that grant or select
lspby exact name, such asdefaultTools,--tools, AdvisorallowedTools, or Minimal Subagents toolsets, must name the new tools instead. - Tools outside the declared core need
codemodeortool_search, or must be named indefaultToolsor--tools. lsp_statusreportsserver_idandroot_pathinstead ofserverIdandrootPath.- Resumed sessions keep their Workspace Edit Previews from
lspresults; apply them withlsp_apply.
Tool annotations
Each tool carries MCP-style annotations that permission extensions can read from
pi.getAllTools(). Pi does not send them to model providers.
| Tools | readOnlyHint |
destructiveHint |
idempotentHint |
openWorldHint |
|---|---|---|---|---|
Queries, lsp_status, and lsp_capabilities |
true |
false |
true |
false |
| Preview tools | true |
false |
false |
false |
lsp_restart |
false |
false |
true |
false |
lsp_apply |
false |
true |
false |
false |
lsp_rename, lsp_code_actions, and the lsp_format_* tools are read-only: they only record a
session-local Workspace Edit Preview, and files change only through lsp_apply. They are not
idempotent, because every call creates a new preview_id. lsp_capabilities is read-only even
though it can start a server. lsp_restart is idempotent: restarting twice leaves one running
server. No tool is open-world, because all of them talk only to configured local language servers.
Structured results
Every tool declares an output schema and returns matching structuredContent, which codemode
scripts receive instead of the text. Reads resolve to
{ results: { server_id, root_path, value }[], warnings, truncated, structured_truncated, spill_path? }, where value is
the server's response with one-based positions and file paths instead of file: URIs. Every
location, symbol, and hierarchy item in a location, symbol, or hierarchy read also carries flat
path, line, and character fields (a symbol's name start, a location's range start), and every
symbol a kind_name such as "class" beside its numeric kind, so a script passes
{ file_path: item.path, line: item.line, character: item.character } straight to lsp_hover,
lsp_find_references, or lsp_rename. The protocol fields stay, and a symbol whose server named no
range has a path but no position. A workspace symbol's position is its location's range start,
which some servers (such as typescript) extend over the whole declaration; take a name's
position from lsp_document_symbols instead. Model-visible text never shows these fields. Position
tools add position: { path, line, character, token?, line_text }: the queried position, the
identifier or punctuation run there (absent on whitespace), and its trimmed line. The
model-visible text of a read is derived from the same data.
lsp_status, lsp_capabilities/lsp_restart, the preview tools, lsp_code_actions, and
lsp_apply have their own shapes; describeTool(name) shows each declaration.
Pi keeps structured results out of model context and session history, so the model-facing output
limit does not cut them. They have their own cap of 1 MiB, like Pi's built-in bash tool. A larger
result is bounded deterministically: the longest strings are shortened (ending in
…[n characters truncated]) and the longest arrays lose their tails, with limits that tighten
step by step until the result fits, so it still matches the output schema. Identifying and enum
fields (state, operation, kinds, ids, paths, server_preview_ids) are never cut.
Two flags tell a script what it received:
structured_truncated: truemeans the structured data is incomplete because it exceeded the cap. The result adds a warning towarningswhere the tool has one.truncated: truemeans either the structured data or only the model-visible text was cut (at 2,000 lines or 50 KB). Whenstructured_truncatedisfalse, the structured data is complete.
spill_path names the Result Spill. After a structured cut it holds the complete structured data
as JSON, including everything the bounded result lost (such as mutation_manifest and
changed_paths); after a text-only cut it holds the complete text. Server-initiated previews
reported with a result appear in server_preview_ids.
Workspace diagnostics use protocol workspace pull when available and cached push diagnostics
otherwise. They never crawl the project to open files. Non-file result URIs such as jar: remain
readable.
In Pi's interactive transcript, each LSP call follows Pi's built-in tool rendering. The header is
the tool name (lsp_hover), the target position or preview, and any rename or search argument;
expanding the row lists every argument. The result previews the tool output: location lists show
15 lines like grep, everything else 10, each followed by Pi's ... (N more lines, ctrl+o to expand) hint. A running call shows Pi's Elapsed footer and a finished one Took. Rendering uses
Pi's active theme and native tool-output expansion controls.
Workspace Edit Preview and apply
lsp_rename, the lsp_format_* tools, and edit-bearing code actions return a persisted Workspace
Edit Preview. They never write immediately. Command-bearing code actions remain visible but cannot
be applied. Server-initiated workspace/applyEdit requests are rejected with applied: false and
exposed as a preview in the active tool result. A preview is applicable only if the result the tool
returns names it: when a call fails, or a server's listing fails, the previews that call created
are discarded. Server-initiated previews are not discarded; they are held until a result reports
them, including after a failed call.
Apply a preview with lsp_apply and { "preview_id": "..." }. lsp_apply is always declared, so
every preview can be applied, including previews recorded by the removed lsp tool in a resumed
session. Before Pi's tool_call hooks run, prepareArguments() replaces any supplied
mutation_manifest with canonical absolute entries shaped as:
{
"mutation_manifest": [
{
"operation": "modify",
"path": "/absolute/canonical/content/target"
},
{
"operation": "rename",
"path": "/absolute/old/name",
"destination_path": "/absolute/new/name"
}
]
}
operation is create, modify, delete, or rename; only rename also has
destination_path. A permission extension may inspect or
block this manifest. Pi LSP validates it again after all hooks, then rechecks preview state,
existence, hashes, modes, and destinations inside Pi's per-file mutation queues, taken sorted by
real path so a symlink cannot order differently than its target. Paths are not restricted to the
workspace.
Application uses temporary-file replacement, preserves modes and UTF-8 BOMs, and keeps originals
for reverse rollback. Content edits follow existing symlinks and expose the canonical target;
rename and delete address the named directory entry. The guarded batch is rollback-capable, not
crash-atomic. A recovery failure is an error result whose changed_paths lists every unrecovered
path; codemode scripts still receive its structured result, with state: "partial_failure".
Post-edit Diagnostics
Pi LSP appends fresh diagnostics to results from:
- any
editorwritetool whose input has a stringpath; apply_patchresults using the currentpi-codex-conversionsuccess/partial-result details;- successful or partially applied LSP previews from
lsp_apply, and from the removedlsptool.
Changed, created, and renamed destination files are diagnosed; deleted files are not. Every
file a Server Definition covers gets an explicit outcome: findings, no diagnostics,
no new diagnostics, timeout, or unavailable server. An unrecognized apply_patch result shape
adds an adapter-version warning. Findings read
path:line:col-end severity [server] source(code): message, one line each, with paths relative to
the working directory, named severities (error, warning, info, hint), the range end and
source(code) rendered as in lsp_diagnostics (the origin is left out when the server names
neither), and the whole message collapsed onto one line. When every changed file is clean, the
section is one line: LSP diagnostics: no diagnostics. Otherwise clean files share one
no diagnostics: a.ts, b.ts line that leaves out any file a server reported a finding for, even
when another server found it clean. Diagnostics preserve
duplicates from independent servers and never change the original tool's success or error state.
Only servers that advertise document diagnostics participate; formatting-only servers remain
available for explicit LSP formatting operations without appearing in Post-edit Diagnostics. A
file that no Server Definition covers, because none handles its language or every one that does
fails its Activation Gate, is skipped silently, as is a file whose only matching Server
Definitions are disabled. When no changed file is covered by an enabled Server Definition, no
diagnostics section is appended (an apply_patch adapter-version warning still is).
Hint-severity findings are omitted from the model-visible section and the transcript entry by
default and reported as N hints omitted (LSP diagnostics: no diagnostics (N hints omitted) when
nothing else is reported); a file left with only hints counts as clean. Set lsp.includeHintDiagnostics
to true to include them. lsp_diagnostics always returns every severity.
Pre-edit Baseline
A native edit or write reports only the findings it introduced in the file it changed.
Before the tool runs, Pi LSP pulls that file's diagnostics from every server that takes part in
Post-edit Diagnostics and records them as its Pre-edit Baseline. Afterwards it lists only findings
that are not in the baseline and counts the rest per severity:
LSP diagnostics
src/a.ts:12:5-18 warning [oxlint] eslint(no-unused-vars): Variable 'x' is declared but never used.
src/a.ts: 1 new; unchanged: 1 error, 12 warnings
A file with existing findings but no new ones reads
src/a.ts: no new diagnostics (unchanged: 1 error), or, when nothing else is reported, the whole
section is one line: LSP diagnostics: no new diagnostics (unchanged: 3 warnings). A file with no
findings at all keeps no diagnostics. Use lsp_diagnostics to see every finding.
- Every severity is compared, errors included: an existing error the edit leaves alone is counted, not listed.
- A finding is the same before and after when its server, severity, code, message, and the trimmed
text of the line it starts on match, not its position, so an edit (or a sibling
editin the same parallel tool batch) that shifts lines does not make existing findings look new. A finding on a line the edit rewrote counts as new. Matching counts copies: two identical findings before and three after is one new finding. - With
lsp.includeHintDiagnosticsoff, hints are neither listed nor counted as unchanged, andN hints omittedcounts only the new ones. With it on, hints are compared like the rest. - No baseline means every finding is listed: a
writethat creates a file, an unreadable file, and a server whose baseline pull failed or ran out of time. That last case adds a note such assrc/a.ts: no pre-edit baseline from oxlint, so all its findings are listed. - The baseline pull shares the 20-second per-call pre-edit budget with the dependent-file scan below and runs concurrently with it. It never blocks or alters the tool call.
apply_patchandlsp_applytake no baseline and list every finding.
Dependent files
An edit can break files other than the ones it changed. For a native edit or write of a file
a server covers, Pi LSP also checks the files that depend on the declarations the edit touched,
and reports the new errors it caused in them under a separate heading:
LSP diagnostics in dependent files (new errors only)
src/pi-todo-extension.ts:1:10-30 error [typescript] ts(2724): '"./todo-list.js"' has no exported member named 'createEmptyTodoStateX'. Did you mean 'createEmptyTodoState'?
How it works, so a pull-only server such as tsc --lsp is covered too (it publishes no workspace
diagnostics and has opened none of the dependents):
- Before the tool runs, Pi LSP asks the server for the file's
textDocument/documentSymbols, picks the declarations the edit'soldText(or, forwrite, the whole file) touches, and askstextDocument/referencesfor each. The files those references lie in are the dependents. - It pulls diagnostics for those dependents and records their errors as a baseline.
- After the tool runs, it pulls them again and reports only errors that were not in the baseline.
An error is the same error before and after when it matches as in the Pre-edit Baseline
(server, severity, code, message, and the trimmed text of the line it starts on), not by
position, so a sibling
editin the same parallel tool batch that shifts a dependent's lines does not make its existing errors look new.
Scope and caps:
- At most 10 touched declarations are searched, and at most 20 dependent files are checked
(the first 20 by path). Files past the cap, or whose diagnostics timed out, are reported as
N dependent files not checked. - Only error-severity findings are reported; errors a dependent file already had are not.
- The before-edit scan shares the 20-second per edit call pre-edit budget with the Pre-edit
Baseline pull; when it runs out, the edit proceeds and its result says
dependent files not checked: the scan ran out of time. - An edit whose declarations have no dependents adds no output and costs one
documentSymboland up to tenreferencesrequests. - Only Server Instances that advertise document symbols, references, and document diagnostics
(pull) take part; a push-only server (typescript-language-server, vtsls, pyright) receives no
scan request. Dependents in
node_modulesare ignored.apply_patchandlsp_applyresults are not scanned for dependents. - A language server only finds references in projects it has loaded. The edited file's own project is loaded; a dependent in an unloaded project is not found.
Findings, unchanged counts, missing-baseline notes, matched-server failures, timeouts, and adapter
warnings also appear in one expandable Post-edit Diagnostics Entry after the current tool batch (a
dependent file's path is marked dependent file); it shows the same new findings and unchanged
counts as the model-visible text. It uses Pi's custom-message look under
a [lsp] edit diagnostics label followed by the counts; its collapsed rendering shows the
first 10 detail lines with Pi's expand hint, and expanding it shows every detail.
Clean results stay silent in the transcript. This entry is
excluded from model context; the model sees diagnostics only in the original mutation result.
Limits and lifecycle
Every LSP tool uses Pi's 2,000-line/50-KB output limit for model-visible text. Complete truncated text is saved as a
Result Spill and the result names its path. Each server's latest 1 MB of stderr is saved in the
session temp directory and failure messages name that path. With --no-session, Pi provides no
session directory, so these files use a private directory under the OS temporary directory instead.
Normal session teardown removes it; forced termination may leave temporary files behind.
Documents must be valid UTF-8. Each server keeps at most 100 synchronized documents and closes least-recently-used documents. Session shutdown requests a graceful LSP shutdown, then terminates the process within the configured timeout.
Security
Trusted project settings can launch arbitrary local executables with all permissions of the Pi process. Review settings and server binaries before trusting a project. Mutation manifests can be blocked by another extension, but Pi LSP itself intentionally imposes no workspace path boundary.