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

@@ -8,6 +8,7 @@ import * as path from "node:path";
import { LspClient } from "./client.ts";
import { findRoot, findServerById, pathToUri } from "./root.ts";
import type { ServerConfig } from "./types.ts";
import { WorkspaceWatcher, type FileEvent } from "./watcher.ts";
import {
logPath,
socketPath,
@@ -37,6 +38,11 @@ interface ClientEntry {
idleTimer: NodeJS.Timeout | null;
ttlMs: number;
lastUsed: number;
// watcher: Lazy - created on first registerCapability for watched files,
// disposed on eviction. Null if the server never registered any watchers
// (or if PI_LSP_DISABLE_WATCHERS is set).
watcher: WorkspaceWatcher | null;
unsubscribeWatchers: (() => void) | null;
}
const entries = new Map<string, ClientEntry>();
@@ -88,6 +94,8 @@ async function getOrCreateEntry(
idleTimer: null,
ttlMs,
lastUsed: Date.now(),
watcher: null,
unsubscribeWatchers: null,
};
entries.set(key, entry);
try {
@@ -96,10 +104,52 @@ async function getOrCreateEntry(
entries.delete(key);
throw err;
}
attachWatcher(entry);
bumpIdle(entry);
return entry;
}
// Attach Watcher - Subscribes to the client's watcher-registration events.
// The first non-empty registration lazily creates the WorkspaceWatcher;
// subsequent register/unregister calls update its pattern set in place.
// Honors PI_LSP_DISABLE_WATCHERS for emergency rollback.
function attachWatcher(entry: ClientEntry): void {
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
const sync = () => {
const patterns = entry.client.getFileWatchers();
if (patterns.length === 0 && !entry.watcher) return;
if (!entry.watcher) {
entry.watcher = new WorkspaceWatcher(entry.rootDir, (events) =>
forwardEvents(entry, events),
);
log(`watcher`, entry.server.id, entry.rootDir, `patterns=${patterns.length}`);
}
if (process.env.LSP_DEBUG) {
log(`watcher patterns`, entry.server.id, JSON.stringify(patterns));
}
entry.watcher.setPatterns(patterns);
};
entry.unsubscribeWatchers = entry.client.onWatchersChanged(sync);
// Initial Sync - Server may have already sent registerCapability during
// initialize before we subscribed.
sync();
}
// Forward Events - Sends a batched workspace/didChangeWatchedFiles to the
// server. We catch errors so a downed transport doesn't crash the daemon.
function forwardEvents(entry: ClientEntry, events: FileEvent[]): void {
try {
if (process.env.LSP_DEBUG) {
log(`watcher fire`, entry.server.id, JSON.stringify(events));
}
entry.client.sendNotification("workspace/didChangeWatchedFiles", {
changes: events,
});
} catch (err) {
log(`watcher send failed`, entry.server.id, (err as Error).message);
}
}
// Bump Idle - Resets the idle eviction timer. Called on every request that
// touches the entry. We log evictions so the daemon's behavior is visible.
function bumpIdle(entry: ClientEntry) {
@@ -113,6 +163,8 @@ function evict(entry: ClientEntry, reason: string) {
log(`evict`, entry.key, reason);
entries.delete(entry.key);
if (entry.idleTimer) clearTimeout(entry.idleTimer);
if (entry.unsubscribeWatchers) entry.unsubscribeWatchers();
void entry.watcher?.dispose();
void entry.client.dispose();
// Auto Shutdown - If this was the last entry, there's nothing left to
// manage. Tear down the daemon so it doesn't sit idle forever.
@@ -292,6 +344,8 @@ function shutdownDaemon(reason: string) {
if (server) server.close();
for (const entry of entries.values()) {
if (entry.idleTimer) clearTimeout(entry.idleTimer);
if (entry.unsubscribeWatchers) entry.unsubscribeWatchers();
void entry.watcher?.dispose();
void entry.client.dispose();
}
entries.clear();