Evicting the entire entry on derived watcher events was overly aggressive.
Instead, clear cached diagnostics and send a reloadProjects command to the
TypeScript language server so it picks up workspace changes without losing
state.
Addresses review feedback on 7787626:
1. Deleted->Created at the same path is a file replacement, not a no-op.
Previous coalescing dropped both Created->Deleted and Deleted->Created;
the latter left the server with no signal to re-read replaced content.
Now: Deleted->Created collapses to Changed, Created->Changed keeps
Created (server didn't know the file at all). Extracted coalesce() so
the matrix is reviewable in one place.
2. setPatterns([]) (server unregistered all watchers) stopped chokidar
but left pending events + timers intact, so a queued batch could
still fire after the server stopped caring. Now drains via
cancelPending() before stopping chokidar.
3. Added ready() returning a promise resolved by chokidar's initial-scan
'ready' event. Production daemon doesn't need to await it (LSP
handshake gives chokidar ample wall-time), but tests now use it
instead of fixed 200ms sleeps - deflakes the suite on slower
filesystems and addresses the (narrow) startup race where a file
created during chokidar's initial crawl could be missed.
4. Unit tests replace 11 hardcoded sleeps with watcher.ready(), and add
coverage for the two coalesce fixes plus the unregister-drains case.
LSP servers maintain their own workspace index built at initialize time
and rely on the client to push file-system events. Previously the daemon
only synced the single file being queried, so externally created/changed
files (codegen, build scripts, git checkout, the agent's own writes from
the perspective of other open files) left the server's index stale until
manual /lsp-destroy.
Each ClientEntry now lazily owns a WorkspaceWatcher (chokidar + picomatch)
that translates FS events into workspace/didChangeWatchedFiles batches.
Patterns come from the server via client/registerCapability (no
speculative watching). Ignores layer a tiny baseline (.git, .DS_Store)
over the repo's root .gitignore, with a fallback list for non-git
workspaces. Events debounce 50ms quiet / 500ms max wait.
Notable: gopls registers absolute-path globs (/abs/root/**/*.go) rather
than relative ones, so compileWatchers() matches each event against both
relative and absolute path forms. Caught by the integration test; unit
regression test added.
Rollback: PI_LSP_DISABLE_WATCHERS=1 disables all watcher creation.
- src/client.ts: honor register/unregisterCapability for
workspace/didChangeWatchedFiles; advertise dynamicRegistration;
expose getFileWatchers/onWatchersChanged/sendNotification
- src/watcher.ts: new WorkspaceWatcher with layered ignores,
debounce+batch, Created+Deleted coalescing, dual-form glob matching
- src/daemon.ts: per-entry watcher lifecycle, PI_LSP_DISABLE_WATCHERS,
LSP_DEBUG-gated pattern/event logging
- test/unit/watcher.test.ts: 11 tests against real chokidar + temp dir
- test/integration/watcher-gopls.test.ts: end-to-end against gopls
- AGENTS.md: new "Workspace File Watching" section
- flake.nix: add go (required by gopls integration test)
- Split vscode-html-language-server into separate servers for HTML, CSS,
and JSON with proper language IDs and file extensions
- Added bash-language-server for shell scripts (.sh, .bash)
- Added sqls for SQL files
- Added timeout wrapper to auto-check diagnostics to prevent blocking pi
Users can now drop a .pi-lsp.json at any ancestor of their working
files to add new LSP servers, override built-in ones, or disable
servers entirely. The nearest config (walking upward) wins.
- New src/config.ts: walks upward for .pi-lsp.json, parses, and
merges with the built-in registry. Cached per config-file path
with mtime invalidation. Falls back to built-ins on parse error.
- Merge rules: matching id shallow-merges (user wins); new id
appends (must include match/command/args/rootMarkers); `disable`
filters at the end.
- src/root.ts: pickServer() now resolves servers via the per-repo
registry. Adds findServerById(filePath, id) and re-exports
getServersForPath() for callers.
- src/daemon.ts: getOrCreateEntry() resolves serverId against the
filePath's config so spawned servers reflect repo overrides.
- index.ts and cli.ts: replace direct `servers` imports with
path-aware getServersForPath() lookups.
- Tests: 9 new unit tests covering merge semantics, walk-up
discovery, mtime invalidation, and graceful fallback.
- Docs: README "Per-Repo Config" section + AGENTS.md updates.
Add diagnosticsOnly?: boolean to ServerConfig. When set, the server is
excluded from pickServer() (hover/definition/references/completion/
documentSymbol) but still included in pickDiagnosticServers() for
lsp_diagnostics and auto-check.
Mark oxlint as diagnosticsOnly: true — it now contributes diagnostics
alongside typescript-language-server without interfering with navigation
or completion tools.
Move all server matching logic to the extension/CLI side. The daemon no
longer calls pickServer() — it receives an explicit serverId (or
serverIds[] for diagnostics) and uses it directly for cache lookup and
server spawning.
Key changes:
- request op requires serverId: string
- diagnostics op requires serverIds: string[] — daemon fans out in
parallel via Promise.allSettled and returns grouped map
- formatDiagnostics() handles grouped results with per-server headers
when multiple servers contribute (single-server omits header)
- CLI picks servers locally before calling daemon helpers
- New pickDiagnosticServers() in extension returns all available,
non-disabled servers matching the file extension
This makes multi-server diagnostics (e.g., typescript-language-server +
oxlint) work naturally — the extension decides which servers to query,
the daemon just executes.
Extract isOnPath() to shared src/util.ts so both the daemon (client.ts)
and extension (root.ts) can use it. Add isServerAvailable() with a
per-process cache to pickServer(), skipping servers whose binary isn't
on PATH before sending requests to the daemon.
This avoids wasted daemon round-trips for missing binaries and sets up
for upcoming multi-server diagnostics fan-out.
Add a limit parameter to formatDiagnostics (default 20 for explicit
lsp_diagnostics calls, 10 for auto-check after edit/write). When
truncated, a summary line indicates how many more diagnostics exist.
Narrow `unknown` types with explicit casts before `in` operator checks
to avoid "Type '{}' may represent a primitive value" errors. Also fix
getActiveTools() which returns string[] (not object[]), removing the
unnecessary .map((t) => t.name). Brings index.ts to zero diagnostics.
Resolve 47 of 52 LSP diagnostics: module-not-found errors for
@mariozechner/pi-coding-agent and typebox, plus cascading implicit-any
errors on callback parameters. Both packages are available on npm and
provided at runtime by pi.
Move pickServer() into the try-catch in runLsp() so UnsupportedExtensionError
is caught directly. Add message-based fallback in both runLsp() and
runDiagnostics() to handle daemon-wrapped errors that come through as plain
Error instances rather than the original typed exception.
This eliminates spurious 'No LSP server registered' warnings during auto-check
after edit/write on files without LSP support (e.g. .md, .txt, .sh).
Add /lsp-servers, /lsp-disable, /lsp-enable, and /lsp-destroy TUI commands.
Disabled servers are tracked in-memory per-extension-instance; the shared
daemon is never mutated by disable/enable. When all servers are disabled,
LSP tools are removed from the active tool set so the LLM won't attempt them.
Also adds a destroy_server daemon operation that kills running LspClient
entries by server ID or all entries.