ccdesk 0.1.0 → 0.2.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 +75 -0
  2. package/README.md +84 -67
  3. package/dist/server/agent-edit.js +77 -0
  4. package/dist/server/backups.js +129 -52
  5. package/dist/server/cli.js +21 -8
  6. package/dist/server/mcp-edit.js +263 -0
  7. package/dist/server/mutations.js +8 -3
  8. package/dist/server/scan/compare.js +34 -0
  9. package/dist/server/scan/context.js +6 -5
  10. package/dist/server/scan/env.js +8 -3
  11. package/dist/server/scan/files.js +118 -30
  12. package/dist/server/scan/index.js +29 -15
  13. package/dist/server/scan/instructions.js +11 -7
  14. package/dist/server/scan/inventory.js +134 -42
  15. package/dist/server/scan/mcp.js +88 -30
  16. package/dist/server/scan/memory.js +8 -9
  17. package/dist/server/scan/path-test.js +32 -10
  18. package/dist/server/scan/permissions.js +22 -6
  19. package/dist/server/scan/plugins.js +18 -11
  20. package/dist/server/scan/projects.js +7 -6
  21. package/dist/server/scan/sandbox.js +161 -0
  22. package/dist/server/scan/settings.js +33 -20
  23. package/dist/server/scan/tools.js +7 -3
  24. package/dist/server/scan/usage.js +119 -46
  25. package/dist/server/schema.js +15 -0
  26. package/dist/server/secrets.js +63 -0
  27. package/dist/server/server.js +222 -128
  28. package/dist/server/settings-edit.js +90 -21
  29. package/dist/server/watch.js +52 -10
  30. package/dist/server/write.js +58 -20
  31. package/dist/shared/compare.js +1 -0
  32. package/dist/shared/hook-events.json +35 -0
  33. package/dist/shared/mcp-server.schema.json +25 -0
  34. package/dist/shared/memory.js +4 -0
  35. package/dist/shared/scopes.js +3 -1
  36. package/dist/shared/types.js +1 -1
  37. package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
  38. package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
  39. package/dist/ui/assets/index-BRgGlpjd.css +1 -0
  40. package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  41. package/dist/ui/index.html +2 -2
  42. package/package.json +10 -4
  43. package/dist/ui/assets/index-BM4K7hSO.css +0 -1
  44. package/dist/ui/assets/index-CN3vkVjv.js +0 -45
@@ -1,41 +1,83 @@
1
1
  import { open, realpath, stat } from "node:fs/promises";
2
2
  import { join, resolve, sep } from "node:path";
3
+ import { storeDirName } from "../../shared/memory.js";
3
4
  import {} from "../../shared/types.js";
4
5
  import {} from "./env.js";
5
- import { isDir, isRecord, isWithin, leadsToSecret, listDir, wildcard, worktrees } from "./files.js";
6
- import { storeDirName } from "./memory.js";
6
+ import { isCredentials, isDir, isRecord, isWithin, listDir, wildcard, worktrees } from "./files.js";
7
7
  import { namesTool, parseRule } from "./tools.js";
8
8
  /**
9
- * Transcripts already counted, by path, so reopening a directory reads only what was added. It
10
- * keeps the commands of every directory opened since ccdesk started, in memory only.
9
+ * Transcripts already counted, by path, so reopening a directory reads only what was added. Kept
10
+ * in memory only, least recently counted first; once they hold more than MAX_CACHED_CALLS calls,
11
+ * the oldest are dropped and read again when needed.
11
12
  */
12
13
  const transcripts = new Map();
14
+ /** Roughly how many calls the cache keeps, beyond those of the directory counted last. */
15
+ export const MAX_CACHED_CALLS = 100_000;
13
16
  /** A transcript is read in parts this size, so one huge file never becomes one huge string. */
14
17
  const CHUNK = 8 * 1024 * 1024;
15
18
  let queue = Promise.resolve();
16
19
  /** Requests waiting their turn, so a burst of the same request runs once. */
17
20
  const waiting = new Map();
21
+ /** How long one count may take before it stops; what it read by then stays counted. */
22
+ const TIME_LIMIT_MS = 60_000;
23
+ /** A count that stopped before it finished, because it took too long or nobody wanted it. */
24
+ export class CountStopped extends Error {
25
+ }
18
26
  /**
19
27
  * Counts the tool calls in the sessions started in `dir`, or anywhere in its repository and its
20
28
  * worktrees, since rules at a repository's root apply there too (docs: permissions#permission-system).
21
29
  * Subagents are included. It also counts the calls you approved or refused by hand, which Claude
22
30
  * Code records on each call's result as `permissionDecision` (not documented; older transcripts
23
- * lack it), and how many calls each rule covers. Requests run one at a time, since they share the cache.
31
+ * lack it), and how many calls each rule covers. Requests run one at a time, since they share the
32
+ * cache. A count stops when every `signal` it was asked with aborts, or after `timeLimitMs`: it
33
+ * rejects with CountStopped and the next one starts, even if a read of the stopped count never
34
+ * returns.
24
35
  */
25
- export function readUsage(env, dir, rules) {
26
- const key = JSON.stringify([env.claudeDir, resolve(dir), rules]);
27
- const queued = waiting.get(key);
28
- if (queued)
29
- return queued;
30
- const run = queue.then(() => {
31
- waiting.delete(key);
32
- return count(env, resolve(dir), rules);
33
- });
34
- waiting.set(key, run);
35
- queue = run.catch(() => undefined);
36
- return run;
36
+ export function readUsage(env, dir, rules, { signal, timeLimitMs = TIME_LIMIT_MS, maxCachedCalls = MAX_CACHED_CALLS, } = {}) {
37
+ const key = JSON.stringify([env.claudeDir, resolve(dir), rules, timeLimitMs, maxCachedCalls]);
38
+ let run = waiting.get(key);
39
+ if (!run) {
40
+ const stop = new AbortController();
41
+ const promise = queue.then(() => {
42
+ waiting.delete(key);
43
+ const during = AbortSignal.any([stop.signal, AbortSignal.timeout(timeLimitMs)]);
44
+ // A stopped count answers right away, even while one of its reads never returns.
45
+ const stopped = new Promise((_, fail) => {
46
+ const onStop = () => fail(stoppedBecause(during));
47
+ if (during.aborted)
48
+ onStop();
49
+ else
50
+ during.addEventListener("abort", onStop, { once: true });
51
+ });
52
+ return Promise.race([count(env, resolve(dir), rules, during, maxCachedCalls), stopped]);
53
+ });
54
+ run = { promise, stop, waiters: 0 };
55
+ waiting.set(key, run);
56
+ // The queue moves on when the count settles or stops: a stopped count changes nothing more.
57
+ queue = promise.catch(() => undefined);
58
+ }
59
+ const current = run;
60
+ if (signal?.aborted)
61
+ return current.promise;
62
+ current.waiters++;
63
+ signal?.addEventListener("abort", () => {
64
+ if (--current.waiters === 0)
65
+ current.stop.abort();
66
+ }, { once: true });
67
+ return current.promise;
37
68
  }
38
- async function count(env, dir, rules) {
69
+ function stoppedBecause(signal) {
70
+ return new CountStopped(signal.reason instanceof DOMException && signal.reason.name === "TimeoutError"
71
+ ? "Counting tool calls took too long and stopped; what was read is kept, so the next count continues."
72
+ : "The count was no longer wanted.");
73
+ }
74
+ /** Throws once a count has to stop, before it changes anything else. */
75
+ function checkStopped(signal) {
76
+ if (signal.aborted)
77
+ throw stoppedBecause(signal);
78
+ }
79
+ async function count(env, dir, rules, signal, maxCachedCalls) {
80
+ checkStopped(signal);
39
81
  const checkouts = await worktrees(dir);
40
82
  // $HOME may end in a slash or be a link, while git prints the resolved path.
41
83
  const homes = new Set([
@@ -50,6 +92,7 @@ async function count(env, dir, rules) {
50
92
  const storeNames = await listDir(projects);
51
93
  const taken = new Set();
52
94
  const calls = [];
95
+ const counted = new Set();
53
96
  let sessions = 0;
54
97
  for (const folder of folders) {
55
98
  const own = storeDirName(folder);
@@ -65,19 +108,23 @@ async function count(env, dir, rules) {
65
108
  if (taken.has(file))
66
109
  continue;
67
110
  // A subagent's session decides; one whose session file is gone goes by its own start.
68
- const start = (await transcript(session))?.startCwd ?? (await transcript(file))?.startCwd;
111
+ const start = (await transcript(env, session, signal))?.startCwd ??
112
+ (await transcript(env, file, signal))?.startCwd;
69
113
  if (start ? !isWithin(folder, start) : name !== own)
70
114
  continue;
71
115
  taken.add(file);
72
- calls.push(...((await transcript(file))?.calls ?? []));
116
+ counted.add(file);
117
+ calls.push(...((await transcript(env, file, signal))?.calls ?? []));
73
118
  if (file === session)
74
119
  sessions++;
75
120
  }
121
+ checkStopped(signal);
76
122
  for (const path of transcripts.keys())
77
123
  if (path.startsWith(store + sep) && !files.has(path))
78
124
  transcripts.delete(path);
79
125
  }
80
126
  }
127
+ trimCache(counted, maxCachedCalls);
81
128
  return {
82
129
  sessions,
83
130
  repository,
@@ -87,6 +134,18 @@ async function count(env, dir, rules) {
87
134
  matches: Object.fromEntries(rules.map((r) => [r.rule, matchCount(r, calls)])),
88
135
  };
89
136
  }
137
+ /** Drops the least recently counted transcripts over the limit, keeping those just counted. */
138
+ function trimCache(counted, maxCalls) {
139
+ let total = 0;
140
+ for (const known of transcripts.values())
141
+ total += known.calls.length;
142
+ for (const [path, known] of transcripts) {
143
+ if (total <= maxCalls || counted.has(path))
144
+ return;
145
+ transcripts.delete(path);
146
+ total -= known.calls.length;
147
+ }
148
+ }
90
149
  /** A store's transcripts, each with the session it belongs to: itself, or a subagent's session. */
91
150
  async function transcriptsIn(store) {
92
151
  const files = new Map();
@@ -100,32 +159,39 @@ async function transcriptsIn(store) {
100
159
  }
101
160
  return files;
102
161
  }
103
- /** One transcript, read up to its end; one that cannot be read keeps what was read before. */
104
- async function transcript(file) {
105
- if (await leadsToSecret(file))
162
+ /**
163
+ * One transcript, read up to its end; one that cannot be read keeps what was read before, and one
164
+ * whose count stops keeps the whole lines read so far.
165
+ */
166
+ async function transcript(env, file, signal) {
167
+ if (await isCredentials(env, file))
106
168
  return null;
107
169
  const info = await stat(file).catch(() => null);
170
+ checkStopped(signal);
108
171
  if (!info?.isFile())
109
172
  return null;
110
173
  let known = transcripts.get(file);
111
- if (!known || info.size < known.offset) {
174
+ if (!known || info.size < known.offset)
112
175
  known = { size: 0, mtimeMs: 0, offset: 0, calls: [], byId: new Map() };
113
- transcripts.set(file, known);
114
- }
176
+ // Set again so the map stays in order of last use.
177
+ transcripts.delete(file);
178
+ transcripts.set(file, known);
115
179
  if (known.size === info.size && known.mtimeMs === info.mtimeMs)
116
180
  return known;
117
181
  try {
118
- await readFrom(file, known, info.size);
182
+ await readFrom(file, known, info.size, signal);
119
183
  known.size = info.size;
120
184
  known.mtimeMs = info.mtimeMs;
121
185
  }
122
- catch {
186
+ catch (error) {
187
+ if (error instanceof CountStopped)
188
+ throw error;
123
189
  // Gone or unreadable since the stat: keep what was counted; the offset still marks it.
124
190
  }
125
191
  return known;
126
192
  }
127
193
  /** Reads whole lines from the offset up to `size`; a line still being written is read next time. */
128
- async function readFrom(file, known, size) {
194
+ async function readFrom(file, known, size, signal) {
129
195
  const handle = await open(file, "r");
130
196
  try {
131
197
  let carried = Buffer.alloc(0);
@@ -133,6 +199,8 @@ async function readFrom(file, known, size) {
133
199
  while (position < size) {
134
200
  const chunk = Buffer.alloc(Math.min(CHUNK, size - position));
135
201
  const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
202
+ // Checked before the cache changes: a later count may already be reading this file.
203
+ checkStopped(signal);
136
204
  if (bytesRead === 0)
137
205
  break;
138
206
  position += bytesRead;
@@ -198,17 +266,23 @@ function read(line, transcript) {
198
266
  }
199
267
  }
200
268
  function callOf(tool, input, at) {
201
- const call = { tool, at };
202
- if ((tool === "Bash" || tool === "Monitor") && typeof input.command === "string")
203
- call.command = input.command;
269
+ const command = (tool === "Bash" || tool === "Monitor") && typeof input.command === "string"
270
+ ? input.command
271
+ : undefined;
272
+ let host;
204
273
  if (tool === "WebFetch" && typeof input.url === "string") {
205
274
  try {
206
- call.host = new URL(input.url).hostname.replace(/\.$/, "");
275
+ host = new URL(input.url).hostname.replace(/\.$/, "");
207
276
  }
208
277
  catch {
209
278
  // Not a URL: no domain rule can match it.
210
279
  }
211
280
  }
281
+ const call = { tool, at, rule: ruleFor(tool, command, host) };
282
+ if (command !== undefined)
283
+ call.commands = everyCommandIn(command);
284
+ if (host !== undefined)
285
+ call.host = host;
212
286
  return call;
213
287
  }
214
288
  function toolUsage(calls) {
@@ -235,9 +309,8 @@ function approvals(calls, rules) {
235
309
  for (const c of calls) {
236
310
  if (c.byYou !== true)
237
311
  continue;
238
- const rule = ruleFor(c);
239
- if (rule)
240
- groups.set(rule, [...(groups.get(rule) ?? []), c]);
312
+ if (c.rule)
313
+ groups.set(c.rule, [...(groups.get(c.rule) ?? []), c]);
241
314
  }
242
315
  const refused = calls.filter((c) => c.byYou === false);
243
316
  const out = [];
@@ -307,13 +380,13 @@ const PATH_TOOLS = new Set(["Read", "Edit", "Write", "NotebookEdit", "Glob", "Gr
307
380
  * permissions#process-wrappers) and file tools. A rule takes no other words from a command, so
308
381
  * arguments never leave the server.
309
382
  */
310
- function ruleFor(call) {
311
- if (call.tool === "Bash") {
312
- if (call.command === undefined || /[`$<>(){}]/.test(call.command))
383
+ function ruleFor(tool, command, host) {
384
+ if (tool === "Bash") {
385
+ if (command === undefined || /[`$<>(){}]/.test(command))
313
386
  return null;
314
- if (/^\s*\w+=/.test(call.command))
387
+ if (/^\s*\w+=/.test(command))
315
388
  return null;
316
- const parts = subcommands(call.command);
389
+ const parts = subcommands(command);
317
390
  if (parts.length !== 1)
318
391
  return null;
319
392
  const [program, ...rest] = (parts[0] ?? "").split(/\s+/);
@@ -324,11 +397,11 @@ function ruleFor(call) {
324
397
  const sub = SUBCOMMANDS[program]?.find((words) => words.split(" ").every((word, i) => rest[i] === word));
325
398
  return sub ? `Bash(${program} ${sub} *)` : null;
326
399
  }
327
- if (call.tool === "WebFetch")
328
- return call.host ? `WebFetch(domain:${call.host})` : null;
329
- if (PATH_TOOLS.has(call.tool) || call.tool === "PowerShell" || call.tool === "Monitor")
400
+ if (tool === "WebFetch")
401
+ return host ? `WebFetch(domain:${host})` : null;
402
+ if (PATH_TOOLS.has(tool) || tool === "PowerShell" || tool === "Monitor")
330
403
  return null;
331
- return call.tool;
404
+ return tool;
332
405
  }
333
406
  const SEPARATORS = /&&|\|\||\|&|[;|&\n]/;
334
407
  /** docs: permissions#process-wrappers */
@@ -416,7 +489,7 @@ function matcherFor({ list, rule }) {
416
489
  const pattern = bashPattern(specifier);
417
490
  if (pattern === null)
418
491
  return null;
419
- return (c) => c.command !== undefined && everyCommandIn(c.command).some(pattern);
492
+ return (c) => c.commands?.some(pattern) === true;
420
493
  }
421
494
  if (tool === "WebFetch" && specifier.startsWith("domain:")) {
422
495
  const host = wildcard(specifier.slice(7).replace(/\.$/, ""), "i");
@@ -0,0 +1,15 @@
1
+ import { Ajv } from "ajv";
2
+ export const ajv = new Ajv({ allErrors: true, strict: false });
3
+ /**
4
+ * What `validate` found wrong with `value`, each led by where: `label` and the path inside it, such
5
+ * as `sandbox.network`.
6
+ */
7
+ export function schemaErrors(validate, value, label) {
8
+ if (validate(value))
9
+ return [];
10
+ return (validate.errors ?? []).map((e) => {
11
+ const where = [label, ...e.instancePath.split("/")].filter(Boolean).join(".");
12
+ const allowed = e.keyword === "enum" ? `: ${e.params.allowedValues.join(", ")}` : "";
13
+ return `${where} ${e.message ?? "is invalid"}${allowed}`;
14
+ });
15
+ }
@@ -0,0 +1,63 @@
1
+ import { isRecord, redactSecrets, shownUrl } from "./scan/files.js";
2
+ /**
3
+ * Helpers for the values ccdesk hides. What is hidden is decided only by redactSecrets; these
4
+ * compare a value with its redacted form.
5
+ */
6
+ /** A value as ccdesk shows it: JSON with hidden values as •••. */
7
+ export function shown(value) {
8
+ return `${JSON.stringify(redactSecrets(value), null, 2)}\n`;
9
+ }
10
+ /** Every value redactSecrets changes, with where it is, what it is, and how it is shown. */
11
+ function hiddenIn(value) {
12
+ const found = [];
13
+ // redactSecrets keeps every key and list length, so the two can be walked side by side.
14
+ const walk = (raw, shownValue, path) => {
15
+ if (Array.isArray(raw) && Array.isArray(shownValue)) {
16
+ for (const [i, inner] of raw.entries())
17
+ walk(inner, shownValue[i], [...path, i]);
18
+ }
19
+ else if (isRecord(raw) && isRecord(shownValue))
20
+ for (const [key, inner] of Object.entries(raw))
21
+ walk(inner, shownValue[key], [...path, key]);
22
+ else if (raw !== shownValue)
23
+ found.push({ path, raw, shown: shownValue });
24
+ };
25
+ walk(value, redactSecrets(value), []);
26
+ return found;
27
+ }
28
+ export function hasHiddenValues(value) {
29
+ return hiddenIn(value).length > 0;
30
+ }
31
+ /**
32
+ * A fingerprint of the hidden values in a JSON file, so a restore can tell whether they changed.
33
+ * Null for a file that is not valid JSON.
34
+ */
35
+ export function hiddenValues(text) {
36
+ try {
37
+ const data = text === null ? null : JSON.parse(text);
38
+ return JSON.stringify(hiddenIn(data).map(({ path, raw }) => [path, raw]));
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * The secret text in a value, to take out of a message that may quote it: an env or header value
46
+ * or a hidden argument whole, the part after `=` of `--token=value`, and a URL's user name,
47
+ * password and query. Longest first, so a secret that contains another is taken out whole.
48
+ */
49
+ export function secretTexts(value) {
50
+ const found = hiddenIn(value).flatMap(({ raw, shown }) => {
51
+ if (typeof raw !== "string")
52
+ return [];
53
+ if (shown === "•••")
54
+ return [raw];
55
+ if (typeof shown === "string" && shown.endsWith("=•••"))
56
+ return [raw.slice(raw.indexOf("=") + 1)];
57
+ if (shownUrl(raw) === raw)
58
+ return [];
59
+ const url = URL.parse(raw);
60
+ return [url?.username, url?.password, url?.search.slice(1)];
61
+ });
62
+ return found.filter((s) => !!s).sort((a, b) => b.length - a.length);
63
+ }