ccdesk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +114 -2
  4. package/dist/server/backups.js +226 -0
  5. package/dist/server/cli.js +92 -0
  6. package/dist/server/mutations.js +188 -0
  7. package/dist/server/open.js +139 -0
  8. package/dist/server/scan/context.js +139 -0
  9. package/dist/server/scan/env.js +23 -0
  10. package/dist/server/scan/files.js +390 -0
  11. package/dist/server/scan/index.js +266 -0
  12. package/dist/server/scan/instructions.js +285 -0
  13. package/dist/server/scan/inventory.js +360 -0
  14. package/dist/server/scan/mcp.js +255 -0
  15. package/dist/server/scan/memory.js +91 -0
  16. package/dist/server/scan/path-test.js +99 -0
  17. package/dist/server/scan/permissions.js +85 -0
  18. package/dist/server/scan/plugins.js +117 -0
  19. package/dist/server/scan/projects.js +100 -0
  20. package/dist/server/scan/settings.js +317 -0
  21. package/dist/server/scan/tools.js +107 -0
  22. package/dist/server/scan/usage.js +441 -0
  23. package/dist/server/server.js +258 -0
  24. package/dist/server/settings-edit.js +152 -0
  25. package/dist/server/watch.js +172 -0
  26. package/dist/server/write.js +195 -0
  27. package/dist/shared/builtin-tools.json +235 -0
  28. package/dist/shared/scopes.js +18 -0
  29. package/dist/shared/settings-scopes.json +208 -0
  30. package/dist/shared/settings.schema.json +4623 -0
  31. package/dist/shared/types.js +2 -0
  32. package/dist/ui/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  33. package/dist/ui/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  34. package/dist/ui/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  35. package/dist/ui/assets/index-BM4K7hSO.css +1 -0
  36. package/dist/ui/assets/index-CN3vkVjv.js +45 -0
  37. package/dist/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  38. package/dist/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  39. package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  40. package/dist/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  41. package/dist/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  42. package/dist/ui/index.html +15 -0
  43. package/dist/ui/logo.png +0 -0
  44. package/package.json +80 -4
@@ -0,0 +1,258 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { createServer } from "node:http";
3
+ import { extname, isAbsolute, join, normalize, resolve, sep } from "node:path";
4
+ import {} from "../shared/types.js";
5
+ import { listBackups, previewRestore, restoreBackup } from "./backups.js";
6
+ import { changeMemory, isMemoryAction, previewMemoryChange } from "./mutations.js";
7
+ import { openInEditor } from "./open.js";
8
+ import {} from "./scan/env.js";
9
+ import { isRecord } from "./scan/files.js";
10
+ import { scanDirectory, scanOverview } from "./scan/index.js";
11
+ import { readUsage } from "./scan/usage.js";
12
+ import { testPath } from "./scan/path-test.js";
13
+ import { previewPermissionEdit, previewSettingsEdit, savePermissionEdit, saveSettingsEdit, } from "./settings-edit.js";
14
+ import { Watcher } from "./watch.js";
15
+ import { isCredentials, RefusedError } from "./write.js";
16
+ const MIME = {
17
+ ".html": "text/html; charset=utf-8",
18
+ ".js": "text/javascript; charset=utf-8",
19
+ ".css": "text/css; charset=utf-8",
20
+ ".svg": "image/svg+xml",
21
+ ".woff2": "font/woff2",
22
+ ".png": "image/png",
23
+ ".json": "application/json",
24
+ };
25
+ const SECURITY_HEADERS = {
26
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
27
+ "X-Content-Type-Options": "nosniff",
28
+ "Referrer-Policy": "no-referrer",
29
+ "Cache-Control": "no-store",
30
+ };
31
+ /** A request ccdesk will not take, answered with `status`. */
32
+ class RequestError extends Error {
33
+ status;
34
+ constructor(status, message) {
35
+ super(message);
36
+ this.status = status;
37
+ }
38
+ }
39
+ export function createCcdeskServer(options) {
40
+ const watcher = new Watcher(options.env);
41
+ const server = createServer((req, res) => {
42
+ handle(options, watcher, server, req, res).catch((error) => {
43
+ if (error instanceof RequestError)
44
+ return send(res, error.status, { error: error.message });
45
+ send(res, 500, { error: error instanceof Error ? error.message : String(error) });
46
+ });
47
+ });
48
+ server.on("close", () => watcher.close());
49
+ return server;
50
+ }
51
+ async function handle(options, watcher, server, req, res) {
52
+ const address = server.address();
53
+ const port = typeof address === "object" && address ? address.port : 0;
54
+ // Reject DNS-rebinding requests: only our own loopback origin may talk to this server.
55
+ const allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
56
+ if (!allowedHosts.includes(req.headers.host ?? ""))
57
+ return send(res, 421, { error: "Unexpected host" });
58
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
59
+ if (!url.pathname.startsWith("/api/"))
60
+ return serveStatic(options.uiDir, url.pathname, res);
61
+ if (req.headers["x-ccdesk-token"] !== options.token)
62
+ return send(res, 401, { error: "Missing or wrong token" });
63
+ const origin = req.headers.origin;
64
+ if (req.method !== "GET" &&
65
+ origin !== undefined &&
66
+ !allowedHosts.some((h) => origin === `http://${h}`)) {
67
+ return send(res, 403, { error: "Cross-origin request refused" });
68
+ }
69
+ const { env, launchDir } = options;
70
+ if (req.method === "GET" && url.pathname === "/api/overview") {
71
+ return send(res, 200, await scanOverview(env, launchDir));
72
+ }
73
+ if (req.method === "GET" && url.pathname === "/api/directory") {
74
+ const path = url.searchParams.get("path");
75
+ if (!path)
76
+ return send(res, 400, { error: "path is required" });
77
+ const view = await scanDirectory(env, path);
78
+ watcher.track(view.path, view);
79
+ return send(res, 200, view);
80
+ }
81
+ if (req.method === "POST" && url.pathname === "/api/usage") {
82
+ const body = await readBody(req);
83
+ const { path } = stringFields(body, ["path"]);
84
+ const rules = body.rules;
85
+ if (!Array.isArray(rules))
86
+ return send(res, 400, { error: "rules must be a list" });
87
+ return send(res, 200, await readUsage(env, path, rules.map((r) => {
88
+ const { list, rule } = stringFields(r, ["list", "rule"]);
89
+ if (!LISTS.includes(list))
90
+ throw new RequestError(400, "a rule needs a list: allow, ask or deny");
91
+ return { list: list, rule };
92
+ })));
93
+ }
94
+ if (req.method === "POST" && url.pathname === "/api/memory/preview") {
95
+ const { action, path } = await fields(req, ["action", "path"]);
96
+ if (!isMemoryAction(action))
97
+ return send(res, 400, { error: "unknown memory action" });
98
+ return answer(res, () => previewMemoryChange(env, action, path));
99
+ }
100
+ if (req.method === "POST" && url.pathname === "/api/memory/change") {
101
+ const { action, path, revision } = await fields(req, ["action", "path", "revision"]);
102
+ if (!isMemoryAction(action))
103
+ return send(res, 400, { error: "unknown memory action" });
104
+ return answer(res, () => changeMemory(env, action, path, revision));
105
+ }
106
+ if (req.method === "POST" && url.pathname === "/api/open") {
107
+ const { path } = await fields(req, ["path"]);
108
+ if (!isAbsolute(path))
109
+ return send(res, 400, { error: "an absolute path is required" });
110
+ const info = await stat(path).catch(() => null);
111
+ if (!info?.isFile() && !info?.isDirectory())
112
+ return send(res, 409, { error: "That file or folder no longer exists." });
113
+ if (await isCredentials(env, path))
114
+ return send(res, 409, { error: "ccdesk does not open credentials." });
115
+ const open = options.openFile ?? openInEditor;
116
+ return answer(res, async () => ({ editor: await open(path) }));
117
+ }
118
+ if (req.method === "POST" && url.pathname === "/api/settings/preview") {
119
+ const edit = settingsEditFrom(await readBody(req));
120
+ return answer(res, () => previewSettingsEdit(env, edit));
121
+ }
122
+ if (req.method === "POST" && url.pathname === "/api/settings/save") {
123
+ const body = await readBody(req);
124
+ const edit = settingsEditFrom(body);
125
+ const { revision } = stringFields(body, ["revision"]);
126
+ return answer(res, () => saveSettingsEdit(env, edit, revision));
127
+ }
128
+ if (req.method === "POST" && url.pathname === "/api/permissions/preview") {
129
+ const edit = permissionEditFrom(await readBody(req));
130
+ return answer(res, () => previewPermissionEdit(env, edit));
131
+ }
132
+ if (req.method === "POST" && url.pathname === "/api/permissions/save") {
133
+ const body = await readBody(req);
134
+ const edit = permissionEditFrom(body);
135
+ const { revision } = stringFields(body, ["revision"]);
136
+ return answer(res, () => savePermissionEdit(env, edit, revision));
137
+ }
138
+ if (req.method === "GET" && url.pathname === "/api/watch") {
139
+ const dir = url.searchParams.get("dir");
140
+ const since = Number(url.searchParams.get("since") ?? "0");
141
+ if (!dir || !isAbsolute(dir) || !Number.isInteger(since))
142
+ return send(res, 400, { error: "dir is required" });
143
+ // Stop waiting when the page goes away or asks for another directory.
144
+ const stop = new AbortController();
145
+ res.once("close", () => stop.abort());
146
+ return send(res, 200, await watcher.wait(resolve(dir), since, stop.signal));
147
+ }
148
+ if (req.method === "GET" && url.pathname === "/api/path-test") {
149
+ const dir = url.searchParams.get("dir");
150
+ const file = url.searchParams.get("file");
151
+ if (!dir || !isAbsolute(dir) || !file)
152
+ return send(res, 400, { error: "dir and file are required" });
153
+ return answer(res, () => testPath(env, dir, file));
154
+ }
155
+ if (req.method === "GET" && url.pathname === "/api/backups") {
156
+ const before = url.searchParams.get("before") ?? undefined;
157
+ const limit = Number(url.searchParams.get("limit") ?? "50");
158
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200)
159
+ return send(res, 400, { error: "limit must be 1 to 200" });
160
+ return send(res, 200, await listBackups(env, { ...(before ? { before } : {}), limit }));
161
+ }
162
+ if (req.method === "POST" && url.pathname === "/api/backups/preview") {
163
+ const { id } = await fields(req, ["id"]);
164
+ return answer(res, () => previewRestore(env, id));
165
+ }
166
+ if (req.method === "POST" && url.pathname === "/api/backups/restore") {
167
+ const { id, revision } = await fields(req, ["id", "revision"]);
168
+ return answer(res, () => restoreBackup(env, id, revision));
169
+ }
170
+ return send(res, 404, { error: "Not found" });
171
+ }
172
+ const SCOPES = ["managed", "user", "project", "local"];
173
+ const LISTS = ["allow", "ask", "deny"];
174
+ /** The named string fields of a JSON body; anything else is a 400. */
175
+ function stringFields(body, names) {
176
+ if (!isRecord(body) || names.some((n) => typeof body[n] !== "string"))
177
+ throw new RequestError(400, `${names.join(", ")} ${names.length === 1 ? "is" : "are"} required`);
178
+ return body;
179
+ }
180
+ async function fields(req, names) {
181
+ return stringFields(await readBody(req), names);
182
+ }
183
+ function settingsEditFrom(body) {
184
+ const { dir, scope, key } = stringFields(body, ["dir", "scope", "key"]);
185
+ if (!SCOPES.includes(scope))
186
+ throw new RequestError(400, "unknown scope");
187
+ const value = body.value;
188
+ return { dir, scope: scope, key, ...(value === undefined ? {} : { value }) };
189
+ }
190
+ function permissionEditFrom(body) {
191
+ const { dir, file } = stringFields(body, ["dir", "file"]);
192
+ const rule = (value) => {
193
+ if (value === undefined)
194
+ return undefined;
195
+ const { list, rule } = stringFields(value, ["list", "rule"]);
196
+ if (!LISTS.includes(list) || rule.trim() === "")
197
+ throw new RequestError(400, "a rule needs a list (allow, ask or deny) and text");
198
+ return { list: list, rule };
199
+ };
200
+ const { remove, add } = body;
201
+ const edit = { dir, file };
202
+ const removed = rule(remove);
203
+ const added = rule(add);
204
+ if (removed)
205
+ edit.remove = removed;
206
+ if (added)
207
+ edit.add = added;
208
+ if (!removed && !added)
209
+ throw new RequestError(400, "remove or add is required");
210
+ return edit;
211
+ }
212
+ async function answer(res, action) {
213
+ try {
214
+ send(res, 200, await action());
215
+ }
216
+ catch (error) {
217
+ if (error instanceof RefusedError)
218
+ return send(res, 409, { error: error.message });
219
+ throw error;
220
+ }
221
+ }
222
+ const MAX_BODY = 64 * 1024;
223
+ async function readBody(req) {
224
+ if (!(req.headers["content-type"] ?? "").startsWith("application/json"))
225
+ throw new RequestError(415, "Send JSON");
226
+ const chunks = [];
227
+ let size = 0;
228
+ for await (const chunk of req) {
229
+ size += chunk.length;
230
+ if (size > MAX_BODY)
231
+ throw new RequestError(413, "Request too large");
232
+ chunks.push(chunk);
233
+ }
234
+ try {
235
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
236
+ }
237
+ catch {
238
+ throw new RequestError(400, "Not valid JSON");
239
+ }
240
+ }
241
+ async function serveStatic(uiDir, pathname, res) {
242
+ if (!uiDir)
243
+ return send(res, 404, { error: "UI is served by the Vite dev server" });
244
+ const rel = normalize(decodeURIComponent(pathname)).replace(/^[/\\]+/, "");
245
+ const target = join(uiDir, rel === "" ? "index.html" : rel);
246
+ if (!target.startsWith(uiDir + sep) && target !== uiDir)
247
+ return send(res, 403, { error: "Forbidden" });
248
+ const file = await readFile(target).catch(() => null);
249
+ // Unknown paths fall back to the app shell so client-side routes reload correctly.
250
+ const body = file ?? (await readFile(join(uiDir, "index.html")));
251
+ const type = file ? (MIME[extname(target)] ?? "application/octet-stream") : MIME[".html"];
252
+ res.writeHead(200, { ...SECURITY_HEADERS, "Content-Type": type });
253
+ res.end(body);
254
+ }
255
+ function send(res, status, body) {
256
+ res.writeHead(status, { ...SECURITY_HEADERS, "Content-Type": "application/json" });
257
+ res.end(JSON.stringify(body));
258
+ }
@@ -0,0 +1,152 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { Ajv } from "ajv";
3
+ import { allowedScopes, ignoredReason } from "../shared/scopes.js";
4
+ import schema from "../shared/settings.schema.json" with { type: "json" };
5
+ import {} from "../shared/types.js";
6
+ import {} from "./scan/env.js";
7
+ import { isRecord, jsonSyntaxError, redactSecrets, revisionOf } from "./scan/files.js";
8
+ import { settingsFiles } from "./scan/settings.js";
9
+ import { RefusedError, readIfExists, recordChange, serially, writeAtomic } from "./write.js";
10
+ const properties = schema.definitions.ClaudeCodeSettings.properties;
11
+ const ajv = new Ajv({ allErrors: true, strict: false });
12
+ const validators = new Map();
13
+ function validatorFor(key) {
14
+ if (!Object.hasOwn(properties, key))
15
+ return null;
16
+ let validate = validators.get(key);
17
+ if (!validate) {
18
+ validate = ajv.compile(properties[key]);
19
+ validators.set(key, validate);
20
+ }
21
+ return validate;
22
+ }
23
+ function shown(data) {
24
+ return `${JSON.stringify(redactSecrets(data), null, 2)}\n`;
25
+ }
26
+ function hasHiddenValues(value) {
27
+ return JSON.stringify(redactSecrets(value)) !== JSON.stringify(value);
28
+ }
29
+ async function editableFile(env, dir, pick, missing = "That is not a settings file Claude Code reads here.") {
30
+ const file = (await settingsFiles(env, dir)).find(pick);
31
+ if (!file)
32
+ throw new RefusedError(missing);
33
+ if (file.scope === "managed")
34
+ throw new RefusedError("Managed settings are set by your organisation.");
35
+ if (file.scope !== "user" && !(await stat(dir).catch(() => null))?.isDirectory())
36
+ throw new RefusedError(`${dir} is not a directory on this machine.`);
37
+ return file;
38
+ }
39
+ async function readSettings(env, path) {
40
+ const raw = await readIfExists(env, path);
41
+ if (raw === null || raw.trim() === "")
42
+ return { raw, data: {} };
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ }
47
+ catch (error) {
48
+ throw new RefusedError(`${path} is ${jsonSyntaxError(error)}; fix it by hand first.`);
49
+ }
50
+ if (!isRecord(parsed))
51
+ throw new RefusedError(`${path} does not hold a JSON object.`);
52
+ return { raw, data: parsed };
53
+ }
54
+ /** Works out the file change that sets or removes one key, refusing what ccdesk cannot do safely. */
55
+ async function planKey(env, file, key, value) {
56
+ if (!allowedScopes(key).includes(file.scope))
57
+ throw new RefusedError(`${ignoredReason(key)}, so a ${file.scope} value would do nothing.`);
58
+ const validate = validatorFor(key);
59
+ if (!validate)
60
+ throw new RefusedError(`${key} is not in Claude Code's settings schema (${schema.$comment}).`);
61
+ const { path } = file;
62
+ const { raw, data } = await readSettings(env, path);
63
+ const nextValue = value(data);
64
+ // ccdesk never shows env or header values, so it must not write them either.
65
+ if (hasHiddenValues({ [key]: data[key] }) || hasHiddenValues({ [key]: nextValue }))
66
+ throw new RefusedError(`${key} holds hidden values; edit it in the file instead.`);
67
+ const before = raw === null ? null : shown(data);
68
+ // The diff is rendered from the parsed file, so a file in another layout is reported as
69
+ // reformatted instead of diffing its raw text.
70
+ const reformats = raw !== null && raw.trim() !== "" && raw !== `${JSON.stringify(data, null, 2)}\n`;
71
+ const base = { path, before, revision: revisionOf(raw), reformats };
72
+ const next = { ...data };
73
+ if (nextValue === undefined) {
74
+ delete next[key];
75
+ }
76
+ else if (!validate(nextValue)) {
77
+ const errors = (validate.errors ?? []).map((e) => `${key}${e.instancePath.replaceAll("/", ".")} ${e.message ?? "is invalid"}`);
78
+ return {
79
+ path,
80
+ scope: file.scope,
81
+ raw,
82
+ next: null,
83
+ preview: { ...base, after: before ?? "", errors },
84
+ };
85
+ }
86
+ else {
87
+ next[key] = nextValue;
88
+ }
89
+ return {
90
+ path,
91
+ scope: file.scope,
92
+ raw,
93
+ next: `${JSON.stringify(next, null, 2)}\n`,
94
+ preview: { ...base, after: shown(next), errors: [] },
95
+ };
96
+ }
97
+ async function planSettingsEdit(env, edit) {
98
+ const file = await editableFile(env, edit.dir, (f) => f.scope === edit.scope && !f.legacy, edit.scope === "project"
99
+ ? "In your home directory this file is your user settings; edit it as User."
100
+ : undefined);
101
+ return planKey(env, file, edit.key, () => edit.value);
102
+ }
103
+ /** One rule taken out of its list and/or added to another, applied to the file as it is on disk. */
104
+ async function planPermissionEdit(env, edit) {
105
+ const file = await editableFile(env, edit.dir, (f) => f.path === edit.file);
106
+ return planKey(env, file, "permissions", (data) => {
107
+ const permissions = isRecord(data.permissions) ? { ...data.permissions } : {};
108
+ const { remove, add } = edit;
109
+ if (remove) {
110
+ const list = Array.isArray(permissions[remove.list])
111
+ ? permissions[remove.list]
112
+ : [];
113
+ if (!list.includes(remove.rule))
114
+ throw new RefusedError(`${remove.rule} is no longer in ${remove.list} in this file.`);
115
+ const kept = list.filter((r) => r !== remove.rule);
116
+ if (kept.length > 0)
117
+ permissions[remove.list] = kept;
118
+ else
119
+ delete permissions[remove.list];
120
+ }
121
+ if (add) {
122
+ const list = Array.isArray(permissions[add.list]) ? permissions[add.list] : [];
123
+ if (!list.includes(add.rule))
124
+ permissions[add.list] = [...list, add.rule];
125
+ }
126
+ return Object.keys(permissions).length > 0 ? permissions : undefined;
127
+ });
128
+ }
129
+ /** Writes a planned edit, but only if the file is still the revision the preview was made from. */
130
+ async function commit(env, plan, expectedRevision) {
131
+ if (plan.next === null)
132
+ throw new RefusedError(plan.preview.errors.join("; "));
133
+ if (plan.preview.revision !== expectedRevision)
134
+ throw new RefusedError("The file changed since you reviewed this edit; review it again.");
135
+ const next = plan.next;
136
+ return recordChange(env, { kind: "settings", target: plan.path, before: plan.raw }, async () => {
137
+ await writeAtomic(env, plan.path, next, expectedRevision);
138
+ return { target: plan.path, revision: revisionOf(next) };
139
+ });
140
+ }
141
+ export async function previewSettingsEdit(env, edit) {
142
+ return (await planSettingsEdit(env, edit)).preview;
143
+ }
144
+ export function saveSettingsEdit(env, edit, expectedRevision) {
145
+ return serially(async () => commit(env, await planSettingsEdit(env, edit), expectedRevision));
146
+ }
147
+ export async function previewPermissionEdit(env, edit) {
148
+ return (await planPermissionEdit(env, edit)).preview;
149
+ }
150
+ export function savePermissionEdit(env, edit, expectedRevision) {
151
+ return serially(async () => commit(env, await planPermissionEdit(env, edit), expectedRevision));
152
+ }
@@ -0,0 +1,172 @@
1
+ import { watch } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import {} from "../shared/types.js";
4
+ import {} from "./scan/env.js";
5
+ import { isNetworkPath, mainWorktreeRoot } from "./scan/files.js";
6
+ import { storeDirName } from "./scan/memory.js";
7
+ /** How long one request waits for a change before answering "nothing changed". */
8
+ const WAIT_MS = 25_000;
9
+ /** Editors write a file in several steps; one change covers them all. */
10
+ const SETTLE_MS = 300;
11
+ /** Watchers outlive a request by this much, so nothing is missed between two polls. */
12
+ const IDLE_MS = 60_000;
13
+ const CONFIG_FOLDERS = ["rules", "agents", "skills", "commands", "output-styles"];
14
+ /**
15
+ * The files and folders whose changes alter what ccdesk shows for `dir`. A `.claude` folder is
16
+ * watched only for its config files and folders, never recursively, because Claude Code keeps
17
+ * whole checkouts in `.claude/worktrees`. ~/.claude.json is left out: Claude Code rewrites it
18
+ * during every session, and focusing the tab picks up its changes.
19
+ */
20
+ async function targets(env, dir, extra) {
21
+ const claude = (folder) => [
22
+ {
23
+ path: folder,
24
+ recursive: false,
25
+ names: ["settings.json", "settings.local.json", "CLAUDE.md", "AGENTS.md", ...CONFIG_FOLDERS],
26
+ },
27
+ ...CONFIG_FOLDERS.map((sub) => ({ path: join(folder, sub), recursive: true })),
28
+ ];
29
+ const memoryKey = (await mainWorktreeRoot(dir)) ?? dir;
30
+ const out = [
31
+ ...claude(env.claudeDir),
32
+ { path: join(env.claudeDir, "plugins"), recursive: false },
33
+ { path: join(env.claudeDir, "projects", storeDirName(memoryKey), "memory"), recursive: false },
34
+ { path: env.managedDir, recursive: false },
35
+ { path: join(env.managedDir, "managed-settings.d"), recursive: false },
36
+ ...extra,
37
+ ];
38
+ // The start directory and every parent can hold instructions, rules and settings.
39
+ for (let d = dir;; d = dirname(d)) {
40
+ out.push({
41
+ path: d,
42
+ recursive: false,
43
+ names: ["CLAUDE.md", "CLAUDE.local.md", "AGENTS.md", ".mcp.json", ".claude"],
44
+ });
45
+ if (join(d, ".claude") !== env.claudeDir)
46
+ out.push(...claude(join(d, ".claude")));
47
+ if (dirname(d) === d)
48
+ break;
49
+ }
50
+ if (memoryKey !== dir)
51
+ out.push(...claude(join(memoryKey, ".claude")));
52
+ return out.filter((t) => !isNetworkPath(t.path));
53
+ }
54
+ /** Files a scan read that the fixed list does not cover: a custom memory folder, imports. */
55
+ export function dependencies(view) {
56
+ const files = view.instructions.flatMap((i) => [
57
+ i.realPath ?? i.path,
58
+ ...i.imports.filter((r) => r.exists).map((r) => r.resolved),
59
+ ]);
60
+ const byFolder = new Map();
61
+ for (const file of files) {
62
+ const names = byFolder.get(dirname(file)) ?? new Set();
63
+ names.add(basename(file));
64
+ byFolder.set(dirname(file), names);
65
+ }
66
+ return [
67
+ ...[...byFolder].map(([path, names]) => ({ path, recursive: false, names: [...names] })),
68
+ ...(view.memory ? [{ path: view.memory.dir, recursive: false }] : []),
69
+ ];
70
+ }
71
+ /** One set of file watchers per directory a page is looking at, shared by its requests. */
72
+ export class Watcher {
73
+ env;
74
+ subscriptions = new Map();
75
+ constructor(env) {
76
+ this.env = env;
77
+ }
78
+ /** Remembers what the latest scan of `dir` read, so those files are watched too. */
79
+ track(dir, view) {
80
+ const subscription = this.subscriptions.get(dir);
81
+ const extra = dependencies(view);
82
+ if (!subscription || JSON.stringify(extra) === JSON.stringify(subscription.extra))
83
+ return;
84
+ subscription.extra = extra;
85
+ void this.rewatch(dir, subscription);
86
+ }
87
+ /**
88
+ * Answers once something changed after `since` (ms since the epoch), or after `timeoutMs` with
89
+ * the last change time, whichever comes first.
90
+ */
91
+ async wait(dir, since, signal, timeoutMs = WAIT_MS) {
92
+ const subscription = await this.subscribe(dir);
93
+ clearTimeout(subscription.idle);
94
+ try {
95
+ if (subscription.changedAt <= since && !signal.aborted)
96
+ await new Promise((resolve) => {
97
+ const done = () => {
98
+ clearTimeout(timer);
99
+ signal.removeEventListener("abort", done);
100
+ subscription.waiters.delete(done);
101
+ resolve();
102
+ };
103
+ const timer = setTimeout(done, timeoutMs);
104
+ signal.addEventListener("abort", done);
105
+ subscription.waiters.add(done);
106
+ });
107
+ }
108
+ finally {
109
+ if (subscription.waiters.size === 0)
110
+ subscription.idle = setTimeout(() => this.drop(dir), IDLE_MS);
111
+ }
112
+ return { changed: subscription.changedAt > since, at: subscription.changedAt };
113
+ }
114
+ close() {
115
+ for (const dir of [...this.subscriptions.keys()])
116
+ this.drop(dir);
117
+ }
118
+ async subscribe(dir) {
119
+ const existing = this.subscriptions.get(dir);
120
+ if (existing)
121
+ return existing;
122
+ const subscription = {
123
+ watchers: [],
124
+ changedAt: 0,
125
+ waiters: new Set(),
126
+ extra: [],
127
+ };
128
+ this.subscriptions.set(dir, subscription);
129
+ await this.rewatch(dir, subscription);
130
+ return subscription;
131
+ }
132
+ drop(dir) {
133
+ const subscription = this.subscriptions.get(dir);
134
+ if (!subscription)
135
+ return;
136
+ clearTimeout(subscription.idle);
137
+ clearTimeout(subscription.settle);
138
+ for (const w of subscription.watchers)
139
+ w.close();
140
+ this.subscriptions.delete(dir);
141
+ }
142
+ /** (Re)creates the watchers, so a folder created since, such as a new .claude, is watched. */
143
+ async rewatch(dir, subscription) {
144
+ const list = await targets(this.env, dir, subscription.extra);
145
+ for (const w of subscription.watchers)
146
+ w.close();
147
+ subscription.watchers = [];
148
+ if (this.subscriptions.get(dir) !== subscription)
149
+ return;
150
+ for (const target of list) {
151
+ try {
152
+ const watcher = watch(target.path, { recursive: target.recursive }, (_event, name) => {
153
+ const changed = name === null ? "" : String(name);
154
+ if (target.names && !target.names.includes(basename(changed)))
155
+ return;
156
+ clearTimeout(subscription.settle);
157
+ subscription.settle = setTimeout(() => {
158
+ subscription.changedAt = Date.now();
159
+ for (const wake of [...subscription.waiters])
160
+ wake();
161
+ void this.rewatch(dir, subscription);
162
+ }, SETTLE_MS);
163
+ });
164
+ watcher.on("error", () => watcher.close());
165
+ subscription.watchers.push(watcher);
166
+ }
167
+ catch {
168
+ // A folder that does not exist yet is caught by watching its parent.
169
+ }
170
+ }
171
+ }
172
+ }