diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c4616ac4..f41dc7112 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@
### Features
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling.
+- Canvas host: add `canvasHost.liveReload` to disable file watching + reload injection.
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
diff --git a/docs/configuration.md b/docs/configuration.md
index ade33dde8..df0fb4949 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1043,11 +1043,15 @@ The server:
- also serves A2UI at `/__clawdis__/a2ui/` and is advertised to nodes as `canvasHostUrl`
(always used by nodes for Canvas/A2UI)
+Disable live reload (and file watching) if the directory is large or you hit `EMFILE`:
+- config: `canvasHost: { liveReload: false }`
+
```json5
{
canvasHost: {
root: "~/clawd/canvas",
- port: 18793
+ port: 18793,
+ liveReload: true
}
}
```
diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts
index ec4f6ec9c..ea72989ef 100644
--- a/src/canvas-host/server.test.ts
+++ b/src/canvas-host/server.test.ts
@@ -49,6 +49,42 @@ describe("canvas host", () => {
}
});
+ it("skips live reload injection when disabled", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-"));
+ await fs.writeFile(
+ path.join(dir, "index.html"),
+ "
no-reload",
+ "utf8",
+ );
+
+ const server = await startCanvasHost({
+ runtime: defaultRuntime,
+ rootDir: dir,
+ port: 0,
+ listenHost: "127.0.0.1",
+ allowInTests: true,
+ liveReload: false,
+ });
+
+ try {
+ const res = await fetch(
+ `http://127.0.0.1:${server.port}${CANVAS_HOST_PATH}/`,
+ );
+ const html = await res.text();
+ expect(res.status).toBe(200);
+ expect(html).toContain("no-reload");
+ expect(html).not.toContain(CANVAS_WS_PATH);
+
+ const wsRes = await fetch(
+ `http://127.0.0.1:${server.port}${CANVAS_WS_PATH}`,
+ );
+ expect(wsRes.status).toBe(404);
+ } finally {
+ await server.close();
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
it("serves canvas content from the mounted base path", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-canvas-"));
await fs.writeFile(
diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts
index 00dc1f83e..7694c1d4f 100644
--- a/src/canvas-host/server.ts
+++ b/src/canvas-host/server.ts
@@ -27,6 +27,7 @@ export type CanvasHostOpts = {
port?: number;
listenHost?: string;
allowInTests?: boolean;
+ liveReload?: boolean;
};
export type CanvasHostServerOpts = CanvasHostOpts & {
@@ -45,6 +46,7 @@ export type CanvasHostHandlerOpts = {
rootDir?: string;
basePath?: string;
allowInTests?: boolean;
+ liveReload?: boolean;
};
export type CanvasHostHandler = {
@@ -234,15 +236,19 @@ export async function createCanvasHostHandler(
);
const rootReal = await prepareCanvasRoot(rootDir);
- const wss = new WebSocketServer({ noServer: true });
+ const liveReload = opts.liveReload !== false;
+ const wss = liveReload ? new WebSocketServer({ noServer: true }) : null;
const sockets = new Set();
- wss.on("connection", (ws) => {
- sockets.add(ws);
- ws.on("close", () => sockets.delete(ws));
- });
+ if (wss) {
+ wss.on("connection", (ws) => {
+ sockets.add(ws);
+ ws.on("close", () => sockets.delete(ws));
+ });
+ }
let debounce: NodeJS.Timeout | null = null;
const broadcastReload = () => {
+ if (!liveReload) return;
for (const ws of sockets) {
try {
ws.send("reload");
@@ -261,19 +267,23 @@ export async function createCanvasHostHandler(
};
let watcherClosed = false;
- const watcher = chokidar.watch(rootReal, {
- ignoreInitial: true,
- awaitWriteFinish: { stabilityThreshold: 75, pollInterval: 10 },
- ignored: [
- /(^|[\\/])\../, // dotfiles
- /(^|[\\/])node_modules([\\/]|$)/,
- ],
- });
- watcher.on("all", () => scheduleReload());
- watcher.on("error", (err) => {
+ const watcher = liveReload
+ ? chokidar.watch(rootReal, {
+ ignoreInitial: true,
+ awaitWriteFinish: { stabilityThreshold: 75, pollInterval: 10 },
+ ignored: [
+ /(^|[\\/])\../, // dotfiles
+ /(^|[\\/])node_modules([\\/]|$)/,
+ ],
+ })
+ : null;
+ watcher?.on("all", () => scheduleReload());
+ watcher?.on("error", (err) => {
if (watcherClosed) return;
watcherClosed = true;
- opts.runtime.error(`canvasHost watcher error: ${String(err)}`);
+ opts.runtime.error(
+ `canvasHost watcher error: ${String(err)} (live reload disabled; consider canvasHost.liveReload=false or a smaller canvasHost.root)`,
+ );
void watcher.close().catch(() => {});
});
@@ -282,6 +292,7 @@ export async function createCanvasHostHandler(
socket: Duplex,
head: Buffer,
) => {
+ if (!wss) return false;
const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname !== CANVAS_WS_PATH) return false;
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
@@ -300,9 +311,9 @@ export async function createCanvasHostHandler(
try {
const url = new URL(urlRaw, "http://localhost");
if (url.pathname === CANVAS_WS_PATH) {
- res.statusCode = 426;
+ res.statusCode = liveReload ? 426 : 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
- res.end("upgrade required");
+ res.end(liveReload ? "upgrade required" : "not found");
return true;
}
@@ -350,7 +361,7 @@ export async function createCanvasHostHandler(
if (mime === "text/html") {
const html = await fs.readFile(filePath, "utf8");
res.setHeader("Content-Type", "text/html; charset=utf-8");
- res.end(injectCanvasLiveReload(html));
+ res.end(liveReload ? injectCanvasLiveReload(html) : html);
return true;
}
@@ -374,8 +385,10 @@ export async function createCanvasHostHandler(
close: async () => {
if (debounce) clearTimeout(debounce);
watcherClosed = true;
- await watcher.close().catch(() => {});
- await new Promise((resolve) => wss.close(() => resolve()));
+ await watcher?.close().catch(() => {});
+ if (wss) {
+ await new Promise((resolve) => wss.close(() => resolve()));
+ }
},
};
}
@@ -394,6 +407,7 @@ export async function startCanvasHost(
rootDir: opts.rootDir,
basePath: CANVAS_HOST_PATH,
allowInTests: opts.allowInTests,
+ liveReload: opts.liveReload,
}));
const ownsHandler = opts.ownsHandler ?? opts.handler === undefined;
diff --git a/src/config/types.ts b/src/config/types.ts
index 0f497ed6c..50ff0141d 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -477,6 +477,8 @@ export type CanvasHostConfig = {
root?: string;
/** HTTP port to listen on (default: 18793). */
port?: number;
+ /** Enable live-reload file watching + WS reloads (default: true). */
+ liveReload?: boolean;
};
export type TalkConfig = {
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index 692344640..996d2defb 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -748,6 +748,7 @@ export const ClawdisSchema = z.object({
enabled: z.boolean().optional(),
root: z.string().optional(),
port: z.number().int().positive().optional(),
+ liveReload: z.boolean().optional(),
})
.optional(),
talk: z
diff --git a/src/gateway/server.ts b/src/gateway/server.ts
index 4c9fb42d5..efda86281 100644
--- a/src/gateway/server.ts
+++ b/src/gateway/server.ts
@@ -555,6 +555,7 @@ export async function startGatewayServer(
rootDir: cfgAtStart.canvasHost?.root,
basePath: CANVAS_HOST_PATH,
allowInTests: opts.allowCanvasHostInTests,
+ liveReload: cfgAtStart.canvasHost?.liveReload,
});
if (handler.rootDir) {
canvasHost = handler;
@@ -860,6 +861,7 @@ export async function startGatewayServer(
port: canvasHostPort,
listenHost: bridgeHost,
allowInTests: opts.allowCanvasHostInTests,
+ liveReload: cfgAtStart.canvasHost?.liveReload,
handler: canvasHost ?? undefined,
ownsHandler: canvasHost ? false : undefined,
});