feat(watcher): forward FS events as workspace/didChangeWatchedFiles

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)
This commit is contained in:
2026-05-19 23:43:32 -04:00
parent e143e05758
commit 77876264ee
9 changed files with 869 additions and 5 deletions

View File

@@ -41,6 +41,38 @@ The daemon tracks opened files per-entry in a `Map<uri, mtimeMs>`. On each reque
A per-entry `serializer` promise chain prevents concurrent syncs from racing.
### Workspace File Watching
Each `ClientEntry` lazily owns a `WorkspaceWatcher` (`src/watcher.ts`,
chokidar + picomatch) that translates filesystem events into
`workspace/didChangeWatchedFiles` notifications. This keeps the server's
workspace index fresh when files are created/changed/deleted **outside** of
LSP tool calls (build scripts, codegen, `git checkout`, the agent's own
file writes).
Non-obvious bits:
- **Patterns come from the server.** We honor `client/registerCapability`
for `workspace/didChangeWatchedFiles` and store the registrations on the
`LspClient`. **Don't re-stub those handlers**; they look harmless but
break the entire feature. If a server doesn't register, we don't watch.
- **Servers send mixed pattern forms.** Gopls registers absolute-path
globs (`/abs/root/**/*.go`); others send relative (`**/*.ts`) or
`RelativePattern` objects. `compileWatchers()` tries both relative and
absolute matching against each event so we accept all forms.
- **Ignore layering.** Always-ignore baseline (`.git/`, `.DS_Store`) +
root `.gitignore` parsed via the `ignore` package + a small fallback
for non-git workspaces. Nested gitignores aren't supported yet.
- **Debounce.** 50ms quiet period, capped at 500ms max wait so sustained
event streams (branch switches) still flush in bounded time.
- **Watcher and mtime-sync coexist.** When the agent edits a file we'll
emit `didChangeWatchedFiles` *and* the next request's `syncFile` will
send a `didChange`. Servers treat the two as orthogonal (workspace
index vs. editor buffer) and dedupe internally. This matches VS Code.
- **Rollback.** `PI_LSP_DISABLE_WATCHERS=1` short-circuits all watcher
creation — if something goes wrong in a real workspace, this restores
the prior "only the queried file is synced" behavior.
### Extension vs Daemon Responsibilities
| Concern | Where |
@@ -60,7 +92,8 @@ cli.ts — CLI for testing/debugging (daemon-aware or --no-daemon)
daemon.ts — Entrypoint that starts the daemon process
src/
client.ts — LspClient: spawns a language server, JSON-RPC handshake, file sync
client.ts — LspClient: spawns a language server, JSON-RPC handshake, file sync, file-watcher registrations
watcher.ts — WorkspaceWatcher: chokidar + picomatch → workspace/didChangeWatchedFiles batches
commands.ts — CLI command dispatcher (maps command names → LSP methods)
config.ts — Per-repo `.pi-lsp.json` loader: walk-up + merge with built-ins, mtime cache
daemonClient.ts — High-level helpers (daemonRequest, daemonDiagnostics, etc.)