summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 16:16:45 +0900
committerAdam Malczewski <[email protected]>2026-06-04 16:16:45 +0900
commit81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (patch)
treedff8ce0e55f27b490445ddaf9fb6536d1313ffcf
parent24bdaa6ca0333b91369ac50b23e929f83af01c3a (diff)
downloaddispatch-v2-deprecated.tar.gz
dispatch-v2-deprecated.zip
feat(config): add subdirectory LSP config watchers and fix permission orderingv2-deprecated
- Add watchDirConfig() for per-directory config watching - Register watchers for subdirectories with their own dispatch.toml - Fix permission ordering: move "*" wildcard to front so findLast reaches specific rules first (was silently breaking all specific bash permission rules) - Add comprehensive tests for watcher functionality - Update mocks in test files
-rw-r--r--dispatch.toml8
-rw-r--r--packages/api/src/agent-manager.ts52
-rw-r--r--packages/api/tests/agent-manager.test.ts3
-rw-r--r--packages/api/tests/routes.test.ts3
-rw-r--r--packages/core/src/config/index.ts2
-rw-r--r--packages/core/src/config/watcher.ts58
-rw-r--r--packages/core/src/index.ts1
-rw-r--r--packages/core/tests/config/watcher.test.ts50
8 files changed, 175 insertions, 2 deletions
diff --git a/dispatch.toml b/dispatch.toml
index 9477842..6964993 100644
--- a/dispatch.toml
+++ b/dispatch.toml
@@ -48,11 +48,18 @@ base_url = "https://opencode.ai/zen/go/v1"
[permissions]
read = "allow"
+# NOTE on ordering: rules are flattened in file order and the LAST matching
+# rule wins (see evaluate()'s findLast). So a broad fallback like "*" must be
+# placed FIRST and the more-specific overrides AFTER it — otherwise a trailing
+# "*" would shadow every specific rule above it. (Global+local merge preserves
+# this: global patterns are emitted before local ones so local overrides win.)
+
[permissions.edit]
"*" = "ask"
"src/**" = "allow"
[permissions.bash]
+"*" = "ask"
"npm test" = "allow"
"npm run *" = "allow"
"git status" = "allow"
@@ -63,7 +70,6 @@ read = "allow"
"git commit *" = "allow"
"git push *" = "allow"
"ls *" = "allow"
-"*" = "ask"
[permissions.external_directory]
"~/*" = "ask"
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 913fb15..539663c 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -75,6 +75,7 @@ import {
type UsageStats,
type UserContentPart,
validateConfig,
+ watchDirConfig,
} from "@dispatch/core";
import type { PermissionManager } from "./permission-manager.js";
import { setConfigGetter } from "./routes/config.js";
@@ -299,11 +300,23 @@ export class AgentManager {
* configs are re-read on demand after a clear).
*/
private lspServersByDir: Map<string, ResolvedLspServer[]> = new Map();
+ /**
+ * One file watcher per distinct SUBDIRECTORY config we've cached in
+ * `lspServersByDir`. The main `configWatcher` only watches the root +
+ * global `dispatch.toml`; a tab whose effective working directory is a
+ * subdirectory with its own `dispatch.toml` needs its cache entry cleared
+ * when THAT file changes. Keyed by directory; closed on full reload (the
+ * cache is dropped wholesale then) and in `destroy()`.
+ */
+ private lspDirWatchers: Map<string, { close(): void }> = new Map();
+ /** Root working directory watched by `configWatcher` (constructor). */
+ private rootWorkingDirectory = "";
constructor(permissionManager?: PermissionManager) {
this.permissionManager = permissionManager;
const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
+ this.rootWorkingDirectory = workingDirectory;
// Load initial config
this.config = loadConfig(workingDirectory);
@@ -345,6 +358,10 @@ export class AgentManager {
// so the next tool build re-reads each working directory's
// `dispatch.toml` `[lsp]` block.
this.lspServersByDir.clear();
+ // Tear down the per-subdirectory LSP watchers too; they are lazily
+ // re-registered by `getLspServersForDir` as directories are re-cached.
+ for (const watcher of this.lspDirWatchers.values()) watcher.close();
+ this.lspDirWatchers.clear();
// Re-discover Claude accounts: a config reload may accompany freshly
// imported credentials, and (critically) lets a process that failed
// account discovery at boot recover without a full restart.
@@ -413,10 +430,43 @@ export class AgentManager {
servers = [];
}
this.lspServersByDir.set(dir, servers);
+ // Hot-reload for SUBDIRECTORY configs: the root/global watcher in the
+ // constructor does not cover a nested `dispatch.toml`. Register a
+ // one-per-dir watcher the first time we cache a directory so editing
+ // its config invalidates just this entry (and cached agents) without a
+ // restart. The root working directory is already covered by
+ // `configWatcher`, so skip it to avoid a redundant watch.
+ this.ensureLspDirWatcher(dir);
return servers;
}
/**
+ * Register (once) a file watcher on `<dir>/dispatch.toml` so a change to a
+ * subdirectory config invalidates that directory's LSP cache entry and
+ * any cached agents. No-op for the root working directory (already watched
+ * by `configWatcher`) and for directories already being watched.
+ */
+ private ensureLspDirWatcher(dir: string): void {
+ if (dir === this.rootWorkingDirectory) return;
+ if (this.lspDirWatchers.has(dir)) return;
+ const watcher = watchDirConfig(dir, () => {
+ // Drop just this directory's resolved servers; the next tool build
+ // re-reads (and re-merges global) for it.
+ this.lspServersByDir.delete(dir);
+ // Invalidate cached agents so the next message rebuilds tools with
+ // the updated server set.
+ for (const tabAgent of this.tabAgents.values()) {
+ tabAgent.agent = null;
+ }
+ for (const tabId of this.tabAgents.keys()) {
+ this.emit({ type: "config-reload" }, tabId);
+ this.routeSystemEventToTab(tabId, "config-reload", "Configuration reloaded");
+ }
+ });
+ this.lspDirWatchers.set(dir, watcher);
+ }
+
+ /**
* Build the `onAfterWrite` hook for `createWriteFileTool` when the tab's
* working directory has LSP servers configured. The hook touches the
* just-written file through the LSP and returns a formatted diagnostics
@@ -2393,6 +2443,8 @@ export class AgentManager {
destroy(): void {
this.configWatcher?.close();
this.skillsWatcher?.close();
+ for (const watcher of this.lspDirWatchers.values()) watcher.close();
+ this.lspDirWatchers.clear();
// Shut down all long-lived LSP server processes. Fire-and-forget: the
// promise is detached so `destroy()` stays synchronous (matching its
// existing contract), but every client gets `shutdown()` called.
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index 5032689..7d8342e 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -328,6 +328,9 @@ vi.mock("@dispatch/core", () => ({
createConfigWatcher(_dir: string, _onChange: unknown) {
return { close() {} };
},
+ watchDirConfig(_dir: string, _onChange: unknown) {
+ return { close() {} };
+ },
loadSkills(_dir: string) {
return { skills: [], mappings: [] };
},
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index a1bf4a6..2d5c7c7 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -141,6 +141,9 @@ vi.mock("@dispatch/core", () => ({
createConfigWatcher(_dir: string, _onChange: unknown) {
return { close() {} };
},
+ watchDirConfig(_dir: string, _onChange: unknown) {
+ return { close() {} };
+ },
loadSkills(_dir: string) {
return { skills: [], mappings: [] };
},
diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts
index 4806409..7f76dd7 100644
--- a/packages/core/src/config/index.ts
+++ b/packages/core/src/config/index.ts
@@ -6,4 +6,4 @@ export {
mergeConfigs,
} from "./loader.js";
export { validateConfig } from "./schema.js";
-export { createConfigWatcher } from "./watcher.js";
+export { createConfigWatcher, watchDirConfig } from "./watcher.js";
diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts
index 9414ef6..ad55804 100644
--- a/packages/core/src/config/watcher.ts
+++ b/packages/core/src/config/watcher.ts
@@ -69,3 +69,61 @@ export function createConfigWatcher(
},
};
}
+
+/**
+ * Watch a SINGLE directory's `dispatch.toml` (no global merge, no reload — just
+ * a debounced change signal). Used by the agent manager to invalidate its
+ * per-directory LSP cache when a tab's effective working directory is a
+ * SUBDIRECTORY with its own `dispatch.toml`: the main `createConfigWatcher`
+ * only watches the root + global configs, so without this a nested config edit
+ * would never clear `lspServersByDir[subdir]` and agents there would keep using
+ * stale LSP servers until a root-config change or restart.
+ *
+ * `onChange` fires (debounced) on add/change/unlink of `<dir>/dispatch.toml`.
+ */
+export function watchDirConfig(dir: string, onChange: () => void): { close(): void } {
+ const tomlPath = join(dir, "dispatch.toml");
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
+
+ const watcher = watch(tomlPath, {
+ ignoreInitial: true,
+ persistent: false,
+ });
+
+ const handleChange = () => {
+ if (debounceTimer !== null) clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(() => {
+ debounceTimer = null;
+ try {
+ onChange();
+ } catch (err) {
+ console.warn(
+ `dispatch: dir config watcher onChange error: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }, 300);
+ };
+
+ watcher.on("change", handleChange);
+ watcher.on("add", handleChange);
+ watcher.on("unlink", handleChange);
+ watcher.on("error", (err) => {
+ console.warn(
+ `dispatch: dir config watcher error: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ });
+
+ return {
+ close() {
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ debounceTimer = null;
+ }
+ watcher.close().catch((err) => {
+ console.warn(
+ `dispatch: error closing dir config watcher: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ });
+ },
+ };
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index b636cde..e951d08 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -44,6 +44,7 @@ export {
loadGlobalConfig,
mergeConfigs,
validateConfig,
+ watchDirConfig,
} from "./config/index.js";
// Credentials
export * from "./credentials/index.js";
diff --git a/packages/core/tests/config/watcher.test.ts b/packages/core/tests/config/watcher.test.ts
new file mode 100644
index 0000000..9388c8a
--- /dev/null
+++ b/packages/core/tests/config/watcher.test.ts
@@ -0,0 +1,50 @@
+import { mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { watchDirConfig } from "../../src/config/watcher.js";
+
+const TMP = join("/tmp/opencode", "dispatch-watchdir-test");
+
+beforeEach(() => {
+ mkdirSync(TMP, { recursive: true });
+});
+
+afterEach(() => {
+ rmSync(TMP, { recursive: true, force: true });
+});
+
+function wait(ms: number): Promise<void> {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+describe("watchDirConfig", () => {
+ it("fires onChange (debounced) when <dir>/dispatch.toml changes", async () => {
+ let calls = 0;
+ const handle = watchDirConfig(TMP, () => {
+ calls++;
+ });
+ // Let chokidar finish its initial scan before mutating the file.
+ await wait(200);
+
+ writeFileSync(join(TMP, "dispatch.toml"), `[permissions]\nread = "allow"\n`, "utf-8");
+ // 300ms debounce + chokidar latency.
+ await wait(700);
+
+ handle.close();
+ expect(calls).toBeGreaterThanOrEqual(1);
+ });
+
+ it("does not fire after close()", async () => {
+ let calls = 0;
+ const handle = watchDirConfig(TMP, () => {
+ calls++;
+ });
+ await wait(200);
+ handle.close();
+
+ writeFileSync(join(TMP, "dispatch.toml"), `[permissions]\nedit = "deny"\n`, "utf-8");
+ await wait(700);
+
+ expect(calls).toBe(0);
+ });
+});