ccdesk 0.2.2 → 0.2.3
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.
- package/CHANGELOG.md +25 -0
- package/README.md +6 -3
- package/dist/core/agent-edit.d.ts +6 -0
- package/dist/{server → core}/agent-edit.js +15 -21
- package/dist/core/backups.d.ts +19 -0
- package/dist/{server → core}/backups.js +37 -45
- package/dist/core/core.d.ts +96 -0
- package/dist/core/core.js +57 -0
- package/dist/core/index.d.ts +22 -0
- package/dist/core/index.js +38 -0
- package/dist/core/mcp-edit.d.ts +25 -0
- package/dist/{server → core}/mcp-edit.js +115 -98
- package/dist/core/mutations.d.ts +20 -0
- package/dist/{server → core}/mutations.js +8 -15
- package/dist/core/open.d.ts +31 -0
- package/dist/{server → core}/open.js +39 -12
- package/dist/core/parse-edit.d.ts +12 -0
- package/dist/core/parse-edit.js +64 -0
- package/dist/core/reference.d.ts +3 -0
- package/dist/core/reference.js +11 -0
- package/dist/core/scan/compare.d.ts +7 -0
- package/dist/{server → core}/scan/compare.js +2 -2
- package/dist/core/scan/context.d.ts +13 -0
- package/dist/{server → core}/scan/context.js +55 -32
- package/dist/core/scan/data.d.ts +8 -0
- package/dist/core/scan/data.js +18 -0
- package/dist/core/scan/env.d.ts +26 -0
- package/dist/{server → core}/scan/env.js +7 -3
- package/dist/core/scan/files.d.ts +68 -0
- package/dist/core/scan/files.js +270 -0
- package/dist/core/scan/git.d.ts +14 -0
- package/dist/core/scan/git.js +98 -0
- package/dist/core/scan/index.d.ts +5 -0
- package/dist/{server → core}/scan/index.js +115 -81
- package/dist/core/scan/instructions.d.ts +54 -0
- package/dist/{server → core}/scan/instructions.js +141 -73
- package/dist/core/scan/inventory.d.ts +38 -0
- package/dist/{server → core}/scan/inventory.js +134 -115
- package/dist/core/scan/mcp.d.ts +28 -0
- package/dist/{server → core}/scan/mcp.js +151 -94
- package/dist/core/scan/memory.d.ts +8 -0
- package/dist/{server → core}/scan/memory.js +8 -8
- package/dist/core/scan/path-test.d.ts +9 -0
- package/dist/core/scan/path-test.js +156 -0
- package/dist/core/scan/permissions.d.ts +31 -0
- package/dist/{server → core}/scan/permissions.js +5 -8
- package/dist/core/scan/plugins.d.ts +39 -0
- package/dist/{server → core}/scan/plugins.js +36 -27
- package/dist/core/scan/projects.d.ts +9 -0
- package/dist/{server → core}/scan/projects.js +5 -4
- package/dist/core/scan/sandbox.d.ts +14 -0
- package/dist/{server → core}/scan/sandbox.js +87 -59
- package/dist/core/scan/settings.d.ts +35 -0
- package/dist/{server → core}/scan/settings.js +88 -92
- package/dist/core/scan/tools.d.ts +10 -0
- package/dist/{server → core}/scan/tools.js +28 -20
- package/dist/core/scan/usage.d.ts +25 -0
- package/dist/{server → core}/scan/usage.js +117 -95
- package/dist/core/schema.d.ts +8 -0
- package/dist/core/schema.js +67 -0
- package/dist/core/secrets.d.ts +44 -0
- package/dist/core/secrets.js +187 -0
- package/dist/core/settings-edit.d.ts +9 -0
- package/dist/{server → core}/settings-edit.js +91 -112
- package/dist/core/watch.d.ts +45 -0
- package/dist/{server → core}/watch.js +54 -17
- package/dist/core/write.d.ts +119 -0
- package/dist/{server → core}/write.js +63 -8
- package/dist/server/args.d.ts +32 -0
- package/dist/server/args.js +106 -0
- package/dist/server/cli.d.ts +2 -0
- package/dist/server/cli.js +58 -83
- package/dist/server/commands.d.ts +8 -0
- package/dist/server/commands.js +39 -0
- package/dist/server/server.d.ts +12 -0
- package/dist/server/server.js +80 -195
- package/dist/server/start.d.ts +13 -0
- package/dist/server/start.js +21 -0
- package/dist/shared/compare.d.ts +25 -0
- package/dist/shared/memory.d.ts +2 -0
- package/dist/shared/requests.d.ts +104 -0
- package/dist/shared/requests.js +73 -0
- package/dist/shared/scopes.d.ts +10 -0
- package/dist/shared/settings-catalog.json +2199 -0
- package/dist/shared/types.d.ts +530 -0
- package/dist/shared/types.js +11 -0
- package/dist/ui/assets/index-CH1HoC38.js +60 -0
- package/dist/ui/index.html +1 -1
- package/dist/view/diff.d.ts +16 -0
- package/dist/view/diff.js +85 -0
- package/dist/view/format.d.ts +17 -0
- package/dist/view/format.js +47 -0
- package/dist/view/index.d.ts +11 -0
- package/dist/view/index.js +13 -0
- package/dist/view/lines.d.ts +9 -0
- package/dist/view/lines.js +12 -0
- package/dist/view/path-test.d.ts +3 -0
- package/dist/view/path-test.js +7 -0
- package/dist/view/sections/catalog.d.ts +9 -0
- package/dist/view/sections/catalog.js +47 -0
- package/dist/view/sections/extensions.d.ts +1 -0
- package/dist/view/sections/extensions.js +48 -0
- package/dist/view/sections/hooks.d.ts +1 -0
- package/dist/view/sections/hooks.js +32 -0
- package/dist/view/sections/index.d.ts +6 -0
- package/dist/view/sections/index.js +27 -0
- package/dist/view/sections/instructions.d.ts +6 -0
- package/dist/view/sections/instructions.js +38 -0
- package/dist/view/sections/mcp.d.ts +3 -0
- package/dist/view/sections/mcp.js +38 -0
- package/dist/view/sections/memory.d.ts +1 -0
- package/dist/view/sections/memory.js +31 -0
- package/dist/view/sections/permissions.d.ts +17 -0
- package/dist/view/sections/permissions.js +87 -0
- package/dist/view/sections/settings.d.ts +1 -0
- package/dist/view/sections/settings.js +33 -0
- package/dist/view/sections/tools.d.ts +6 -0
- package/dist/view/sections/tools.js +57 -0
- package/dist/view/sections/types.d.ts +123 -0
- package/dist/view/sections/types.js +10 -0
- package/dist/view/settings.d.ts +12 -0
- package/dist/view/settings.js +31 -0
- package/dist/view/usage.d.ts +8 -0
- package/dist/view/usage.js +18 -0
- package/package.json +13 -2
- package/dist/server/scan/files.js +0 -480
- package/dist/server/scan/path-test.js +0 -136
- package/dist/server/schema.js +0 -15
- package/dist/server/secrets.js +0 -63
- package/dist/ui/assets/index-Dg9dxngR.js +0 -60
|
@@ -2,8 +2,11 @@ import { lstat } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { allowedScopes, EVERY_FILE, ignoredReason } from "../../shared/scopes.js";
|
|
4
4
|
import {} from "../../shared/types.js";
|
|
5
|
+
import { redactSecrets } from "../secrets.js";
|
|
5
6
|
import {} from "./env.js";
|
|
6
|
-
import {
|
|
7
|
+
import { listDir, readJsonObject } from "./files.js";
|
|
8
|
+
import { isRecord } from "./data.js";
|
|
9
|
+
import { mainWorktreeRoot } from "./git.js";
|
|
7
10
|
import { sandboxFilter } from "./sandbox.js";
|
|
8
11
|
/** Lowest precedence first, so later layers override earlier ones. */
|
|
9
12
|
const PRECEDENCE = ["user", "project", "local", "managed"];
|
|
@@ -132,12 +135,15 @@ export async function settingsFiles(env, dir) {
|
|
|
132
135
|
files.push({ scope: "user", path: userFile });
|
|
133
136
|
return files;
|
|
134
137
|
}
|
|
135
|
-
/**
|
|
136
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Settings files that apply in `dir`, highest precedence first. `managed` is what
|
|
140
|
+
* readManagedSettings read, so a scan reads the managed files once.
|
|
141
|
+
*/
|
|
142
|
+
export async function readSettingsLayers(env, dir, managed) {
|
|
137
143
|
const layers = [];
|
|
138
144
|
for (const file of await settingsFiles(env, dir)) {
|
|
139
145
|
if (file.scope === "managed") {
|
|
140
|
-
layers.push(
|
|
146
|
+
layers.push(managedLayer(file.path, managed));
|
|
141
147
|
continue;
|
|
142
148
|
}
|
|
143
149
|
const result = await readJsonObject(env, file.path);
|
|
@@ -185,8 +191,8 @@ export async function readManagedSettings(env) {
|
|
|
185
191
|
}
|
|
186
192
|
return { data, sources, dropIns, errors, blocksStart };
|
|
187
193
|
}
|
|
188
|
-
|
|
189
|
-
const { data, sources, dropIns, errors, blocksStart } =
|
|
194
|
+
function managedLayer(path, managed) {
|
|
195
|
+
const { data, sources, dropIns, errors, blocksStart } = managed;
|
|
190
196
|
const layer = { scope: "managed", path, exists: false, data: null };
|
|
191
197
|
if (data)
|
|
192
198
|
layer.data = redactSecrets(data);
|
|
@@ -211,90 +217,80 @@ export function effectiveSettings(layers) {
|
|
|
211
217
|
}
|
|
212
218
|
const keys = new Set([...byScope.values()].flatMap((data) => Object.keys(data)));
|
|
213
219
|
const filterSandbox = sandboxFilter((key, scope) => allowedScopes(key).includes(scope) ? byScope.get(scope)?.[key] : undefined);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
values.every((v) => Array.isArray(v.value) || isRecord(v.value));
|
|
248
|
-
const strict = STRICTEST[key];
|
|
249
|
-
const decided = strict && strictWinner(strict, values);
|
|
250
|
-
if (strict && decided) {
|
|
251
|
-
const { winner } = decided;
|
|
252
|
-
// Only files that may just tighten it set the key, and none tightens it past the default.
|
|
253
|
-
if (!winner)
|
|
254
|
-
for (const v of values)
|
|
255
|
-
ignored.push({ ...v, reason: "it is not stricter than Claude Code's default" });
|
|
256
|
-
out.push({
|
|
257
|
-
key,
|
|
258
|
-
value: winner?.value,
|
|
259
|
-
from: winner ? [winner.scope] : [],
|
|
260
|
-
merged: false,
|
|
261
|
-
overridden: winner ? values.filter((v) => v !== winner).reverse() : [],
|
|
262
|
-
ignored,
|
|
263
|
-
note: strictNote(strict),
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
else if (mergeable && values.length > 1) {
|
|
267
|
-
out.push({
|
|
268
|
-
key,
|
|
269
|
-
value: withStrictFields(key, values.slice(1).reduce((acc, v) => mergeKey(key, acc, v.value), values[0]?.value), values),
|
|
270
|
-
from: defined,
|
|
271
|
-
merged: true,
|
|
272
|
-
overridden: [],
|
|
273
|
-
ignored,
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
else if (isRecord(top.value)) {
|
|
277
|
-
out.push({
|
|
278
|
-
key,
|
|
279
|
-
value: withStrictFields(key, top.value, values),
|
|
280
|
-
from: [top.scope],
|
|
281
|
-
merged: false,
|
|
282
|
-
overridden: values.slice(0, -1).reverse(),
|
|
283
|
-
ignored,
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
else {
|
|
287
|
-
out.push({
|
|
288
|
-
key,
|
|
289
|
-
value: top.value,
|
|
290
|
-
from: [top.scope],
|
|
291
|
-
merged: false,
|
|
292
|
-
overridden: values.slice(0, -1).reverse(),
|
|
293
|
-
ignored,
|
|
294
|
-
});
|
|
295
|
-
}
|
|
220
|
+
return [...keys]
|
|
221
|
+
.sort()
|
|
222
|
+
.map((key) => effectiveSetting(key, (scope) => byScope.get(scope)?.[key], filterSandbox));
|
|
223
|
+
}
|
|
224
|
+
/** One key's value, from its value in each scope, lowest precedence first. */
|
|
225
|
+
function effectiveSetting(key, valueIn, filterSandbox) {
|
|
226
|
+
const { defined, ignored } = scopesThatCount(key, valueIn);
|
|
227
|
+
const values = defined.map((scope) => {
|
|
228
|
+
const { value, dropped } = withoutIgnoredFields(key, scope, valueIn(scope), filterSandbox);
|
|
229
|
+
for (const d of dropped)
|
|
230
|
+
ignored.push({ scope, ...d });
|
|
231
|
+
return { scope, value };
|
|
232
|
+
});
|
|
233
|
+
const top = values.at(-1);
|
|
234
|
+
if (!top)
|
|
235
|
+
return { key, value: undefined, from: [], merged: false, overridden: [], ignored };
|
|
236
|
+
const strict = STRICTEST[key];
|
|
237
|
+
const decided = strict && strictWinner(strict, values);
|
|
238
|
+
if (strict && decided) {
|
|
239
|
+
const { winner } = decided;
|
|
240
|
+
// Only files that may just tighten it set the key, and none tightens it past the default.
|
|
241
|
+
if (!winner)
|
|
242
|
+
for (const v of values)
|
|
243
|
+
ignored.push({ ...v, reason: "it is not stricter than Claude Code's default" });
|
|
244
|
+
return {
|
|
245
|
+
key,
|
|
246
|
+
value: winner?.value,
|
|
247
|
+
from: winner ? [winner.scope] : [],
|
|
248
|
+
merged: false,
|
|
249
|
+
overridden: winner ? values.filter((v) => v !== winner).reverse() : [],
|
|
250
|
+
ignored,
|
|
251
|
+
note: strictNote(strict),
|
|
252
|
+
};
|
|
296
253
|
}
|
|
297
|
-
|
|
254
|
+
const mergeable = !WHOLE_VALUE_KEYS.has(key) && values.every((v) => Array.isArray(v.value) || isRecord(v.value));
|
|
255
|
+
if (mergeable && values.length > 1)
|
|
256
|
+
return {
|
|
257
|
+
key,
|
|
258
|
+
value: withStrictFields(key, values.slice(1).reduce((acc, v) => mergeKey(key, acc, v.value), values[0]?.value), values),
|
|
259
|
+
from: defined,
|
|
260
|
+
merged: true,
|
|
261
|
+
overridden: [],
|
|
262
|
+
ignored,
|
|
263
|
+
};
|
|
264
|
+
return {
|
|
265
|
+
key,
|
|
266
|
+
value: withStrictFields(key, top.value, values),
|
|
267
|
+
from: [top.scope],
|
|
268
|
+
merged: false,
|
|
269
|
+
overridden: values.slice(0, -1).reverse(),
|
|
270
|
+
ignored,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The scopes whose value for `key` counts, lowest precedence first, and the values Claude Code
|
|
275
|
+
* ignores: ones from files the key is not read from, and, once managed settings set
|
|
276
|
+
* availableModels, every other file's list, since Claude Code uses the managed one as-is.
|
|
277
|
+
*/
|
|
278
|
+
function scopesThatCount(key, valueIn) {
|
|
279
|
+
const allowed = allowedScopes(key);
|
|
280
|
+
const present = PRECEDENCE.filter((scope) => valueIn(scope) !== undefined);
|
|
281
|
+
const ignored = present
|
|
282
|
+
.filter((scope) => !allowed.includes(scope))
|
|
283
|
+
.map((scope) => ({ scope, value: valueIn(scope), reason: ignoredReason(key) }));
|
|
284
|
+
const defined = present.filter((scope) => allowed.includes(scope));
|
|
285
|
+
if (key !== "availableModels" || !defined.includes("managed"))
|
|
286
|
+
return { defined, ignored };
|
|
287
|
+
for (const scope of defined.filter((s) => s !== "managed"))
|
|
288
|
+
ignored.push({
|
|
289
|
+
scope,
|
|
290
|
+
value: valueIn(scope),
|
|
291
|
+
reason: "Managed settings set availableModels, so Claude Code uses that list as-is",
|
|
292
|
+
});
|
|
293
|
+
return { defined: ["managed"], ignored };
|
|
298
294
|
}
|
|
299
295
|
/** An object setting with each nested strictness exception (such as permissions.x) applied. */
|
|
300
296
|
function withStrictFields(key, value, values) {
|
|
@@ -305,9 +301,9 @@ function withStrictFields(key, value, values) {
|
|
|
305
301
|
const [head, field] = path.split(".");
|
|
306
302
|
if (head !== key || !field)
|
|
307
303
|
continue;
|
|
308
|
-
const fieldValues = values
|
|
309
|
-
|
|
310
|
-
|
|
304
|
+
const fieldValues = values.flatMap((v) => isRecord(v.value) && v.value[field] !== undefined
|
|
305
|
+
? [{ scope: v.scope, value: v.value[field] }]
|
|
306
|
+
: []);
|
|
311
307
|
const decided = strictWinner(strict, fieldValues);
|
|
312
308
|
if (decided)
|
|
313
309
|
out = { ...out, [field]: decided.winner?.value };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type BuiltinTool, type Hook, type Permission, type PermissionList } from "../../shared/types.ts";
|
|
2
|
+
/** `Tool` or `Tool(specifier)`; `Tool(*)` names the whole tool too (docs: permissions#permission-rule-syntax). */
|
|
3
|
+
export declare function parseRule(rule: string): {
|
|
4
|
+
tool: string;
|
|
5
|
+
specifier: string | null;
|
|
6
|
+
};
|
|
7
|
+
/** Deny and ask rules may glob the tool name; allow rules name it exactly (docs: permissions#tool-name-wildcards). */
|
|
8
|
+
export declare function namesTool(list: PermissionList, tool: string, name: string): boolean;
|
|
9
|
+
/** Every built-in tool, with the rules and hooks that apply to it here. */
|
|
10
|
+
export declare function builtinTools(permissions: Permission[], hooks: Hook[]): BuiltinTool[];
|
|
@@ -50,31 +50,39 @@ function matcherCovers(matcher, name) {
|
|
|
50
50
|
return false;
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The rules about one tool: `whole` name the tool itself and decide it, `scoped` apply to some of
|
|
55
|
+
* its calls only. EndConversation ignores rules (see builtinTools).
|
|
56
|
+
*/
|
|
57
|
+
function rulesFor(name, rules) {
|
|
58
|
+
const whole = [];
|
|
59
|
+
const scoped = [];
|
|
60
|
+
if (name === EXEMPT)
|
|
61
|
+
return { whole, scoped };
|
|
62
|
+
for (const r of rules) {
|
|
63
|
+
const { tool, specifier } = parseRule(r.rule);
|
|
64
|
+
if (specifier === null) {
|
|
65
|
+
if (namesTool(r.list, tool, name))
|
|
66
|
+
whole.push(r);
|
|
67
|
+
// Monitor's commands follow the Bash rules; its WebSocket watches do not
|
|
68
|
+
// (docs: tools-reference#monitor-tool).
|
|
69
|
+
else if (name === "Monitor" && namesTool(r.list, tool, "Bash"))
|
|
70
|
+
scoped.push(r);
|
|
71
|
+
}
|
|
72
|
+
else if (tool === name && UNCHECKED_PATH_RULE_TOOLS.has(tool)) {
|
|
73
|
+
scoped.push({ ...r, unchecked: true });
|
|
74
|
+
}
|
|
75
|
+
else if (tool === name || coveredTools(tool, r.list).includes(name))
|
|
76
|
+
scoped.push(r);
|
|
77
|
+
}
|
|
78
|
+
return { whole, scoped };
|
|
79
|
+
}
|
|
53
80
|
/** Every built-in tool, with the rules and hooks that apply to it here. */
|
|
54
81
|
export function builtinTools(permissions, hooks) {
|
|
55
82
|
const rules = permissions.flatMap((p) => p.kind === "rule" && p.inEffect ? [{ list: p.list, rule: p.rule, scope: p.scope }] : []);
|
|
56
83
|
const preToolUse = hooks.filter((h) => h.active && h.event === "PreToolUse");
|
|
57
84
|
const tools = table.tools.map(({ name, description, asks }) => {
|
|
58
|
-
const whole =
|
|
59
|
-
const scoped = [];
|
|
60
|
-
for (const r of rules) {
|
|
61
|
-
if (name === EXEMPT)
|
|
62
|
-
break;
|
|
63
|
-
const { tool, specifier } = parseRule(r.rule);
|
|
64
|
-
if (specifier === null) {
|
|
65
|
-
if (namesTool(r.list, tool, name))
|
|
66
|
-
whole.push(r);
|
|
67
|
-
// Monitor's commands follow the Bash rules; its WebSocket watches do not
|
|
68
|
-
// (docs: tools-reference#monitor-tool).
|
|
69
|
-
else if (name === "Monitor" && namesTool(r.list, tool, "Bash"))
|
|
70
|
-
scoped.push(r);
|
|
71
|
-
}
|
|
72
|
-
else if (tool === name && UNCHECKED_PATH_RULE_TOOLS.has(tool)) {
|
|
73
|
-
scoped.push({ ...r, unchecked: true });
|
|
74
|
-
}
|
|
75
|
-
else if (tool === name || coveredTools(tool, r.list).includes(name))
|
|
76
|
-
scoped.push(r);
|
|
77
|
-
}
|
|
85
|
+
const { whole, scoped } = rulesFor(name, rules);
|
|
78
86
|
const decidedBy = CHECK_ORDER.map((list) => whole.find((r) => r.list === list)).find(Boolean);
|
|
79
87
|
const tool = {
|
|
80
88
|
name,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type PermissionList, type Usage } from "../../shared/types.ts";
|
|
2
|
+
import { type ScanEnv } from "./env.ts";
|
|
3
|
+
/** A rule in effect here, as the scan found it. */
|
|
4
|
+
export interface RuleInEffect {
|
|
5
|
+
list: PermissionList;
|
|
6
|
+
rule: string;
|
|
7
|
+
}
|
|
8
|
+
/** A count that stopped before it finished, because it took too long or nobody wanted it. */
|
|
9
|
+
export declare class CountStopped extends Error {
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A counter with its own cache of transcripts and its own queue: counts made through it run one at
|
|
13
|
+
* a time and reuse what earlier ones read. Each core has one.
|
|
14
|
+
*/
|
|
15
|
+
export declare function usageCounter(): (env: ScanEnv, dir: string, rules: RuleInEffect[], { signal, timeLimitMs, maxCachedCalls, }?: {
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
timeLimitMs?: number;
|
|
18
|
+
maxCachedCalls?: number;
|
|
19
|
+
}) => Promise<Usage>;
|
|
20
|
+
/** Counts with a cache and queue shared by every caller in the process; use a core's instead. */
|
|
21
|
+
export declare const readUsage: (env: ScanEnv, dir: string, rules: RuleInEffect[], { signal, timeLimitMs, maxCachedCalls, }?: {
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
timeLimitMs?: number;
|
|
24
|
+
maxCachedCalls?: number;
|
|
25
|
+
}) => Promise<Usage>;
|
|
@@ -1,74 +1,82 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { join, resolve, sep } from "node:path";
|
|
3
3
|
import { storeDirName } from "../../shared/memory.js";
|
|
4
4
|
import {} from "../../shared/types.js";
|
|
5
5
|
import {} from "./env.js";
|
|
6
|
-
import { isCredentials, isDir,
|
|
6
|
+
import { isCredentials, isDir, isWithin, listDir, openUnlessCredentials, wildcard, } from "./files.js";
|
|
7
|
+
import { isRecord } from "./data.js";
|
|
8
|
+
import { worktrees } from "./git.js";
|
|
7
9
|
import { namesTool, parseRule } from "./tools.js";
|
|
8
|
-
/**
|
|
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.
|
|
12
|
-
*/
|
|
13
|
-
const transcripts = new Map();
|
|
14
10
|
/** Roughly how many calls the cache keeps, beyond those of the directory counted last. */
|
|
15
|
-
|
|
11
|
+
const MAX_CACHED_CALLS = 100_000;
|
|
16
12
|
/** A transcript is read in parts this size, so one huge file never becomes one huge string. */
|
|
17
13
|
const CHUNK = 8 * 1024 * 1024;
|
|
18
|
-
let queue = Promise.resolve();
|
|
19
|
-
/** Requests waiting their turn, so a burst of the same request runs once. */
|
|
20
|
-
const waiting = new Map();
|
|
21
14
|
/** How long one count may take before it stops; what it read by then stays counted. */
|
|
22
15
|
const TIME_LIMIT_MS = 60_000;
|
|
23
16
|
/** A count that stopped before it finished, because it took too long or nobody wanted it. */
|
|
24
17
|
export class CountStopped extends Error {
|
|
25
18
|
}
|
|
26
19
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* Subagents are included. It also counts the calls you approved or refused by hand, which Claude
|
|
30
|
-
* Code records on each call's result as `permissionDecision` (not documented; older transcripts
|
|
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.
|
|
20
|
+
* A counter with its own cache of transcripts and its own queue: counts made through it run one at
|
|
21
|
+
* a time and reuse what earlier ones read. Each core has one.
|
|
35
22
|
*/
|
|
36
|
-
export function
|
|
37
|
-
const
|
|
38
|
-
let
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
23
|
+
export function usageCounter() {
|
|
24
|
+
const transcripts = new Map();
|
|
25
|
+
let queue = Promise.resolve();
|
|
26
|
+
/** Requests waiting their turn, so a burst of the same request runs once. */
|
|
27
|
+
const waiting = new Map();
|
|
28
|
+
/**
|
|
29
|
+
* Counts the tool calls in the sessions started in `dir`, or anywhere in its repository and its
|
|
30
|
+
* worktrees, since rules at a repository's root apply there too (docs: permissions#permission-system).
|
|
31
|
+
* Subagents are included. It also counts the calls you approved or refused by hand, which Claude
|
|
32
|
+
* Code records on each call's result as `permissionDecision` (not documented; older transcripts
|
|
33
|
+
* lack it), and how many calls each rule covers. Requests run one at a time, since they share the
|
|
34
|
+
* cache. A count stops when every `signal` it was asked with aborts, or after `timeLimitMs`: it
|
|
35
|
+
* rejects with CountStopped and the next one starts, even if a read of the stopped count never
|
|
36
|
+
* returns.
|
|
37
|
+
*/
|
|
38
|
+
return function countUsage(env, dir, rules, { signal, timeLimitMs = TIME_LIMIT_MS, maxCachedCalls = MAX_CACHED_CALLS, } = {}) {
|
|
39
|
+
const key = JSON.stringify([env.claudeDir, resolve(dir), rules, timeLimitMs, maxCachedCalls]);
|
|
40
|
+
let run = waiting.get(key);
|
|
41
|
+
// Nobody would wait for a new count, so none starts to hold up the queue.
|
|
42
|
+
if (!run && signal?.aborted)
|
|
43
|
+
return Promise.reject(stoppedBecause(signal));
|
|
44
|
+
if (!run) {
|
|
45
|
+
const stop = new AbortController();
|
|
46
|
+
const promise = queue.then(() => {
|
|
47
|
+
waiting.delete(key);
|
|
48
|
+
const during = AbortSignal.any([stop.signal, AbortSignal.timeout(timeLimitMs)]);
|
|
49
|
+
// A stopped count answers right away, even while one of its reads never returns.
|
|
50
|
+
const stopped = new Promise((_, fail) => {
|
|
51
|
+
const onStop = () => fail(stoppedBecause(during));
|
|
52
|
+
if (during.aborted)
|
|
53
|
+
onStop();
|
|
54
|
+
else
|
|
55
|
+
during.addEventListener("abort", onStop, { once: true });
|
|
56
|
+
});
|
|
57
|
+
return Promise.race([
|
|
58
|
+
count(transcripts, env, resolve(dir), rules, during, maxCachedCalls),
|
|
59
|
+
stopped,
|
|
60
|
+
]);
|
|
54
61
|
});
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
run = { promise, stop, waiters: 0 };
|
|
63
|
+
waiting.set(key, run);
|
|
64
|
+
// The queue moves on when the count settles or stops: a stopped count changes nothing more.
|
|
65
|
+
queue = promise.catch(() => undefined);
|
|
66
|
+
}
|
|
67
|
+
const current = run;
|
|
68
|
+
if (signal?.aborted)
|
|
69
|
+
return current.promise;
|
|
70
|
+
current.waiters++;
|
|
71
|
+
signal?.addEventListener("abort", () => {
|
|
72
|
+
if (--current.waiters === 0)
|
|
73
|
+
current.stop.abort();
|
|
74
|
+
}, { once: true });
|
|
64
75
|
return current.promise;
|
|
65
|
-
|
|
66
|
-
signal?.addEventListener("abort", () => {
|
|
67
|
-
if (--current.waiters === 0)
|
|
68
|
-
current.stop.abort();
|
|
69
|
-
}, { once: true });
|
|
70
|
-
return current.promise;
|
|
76
|
+
};
|
|
71
77
|
}
|
|
78
|
+
/** Counts with a cache and queue shared by every caller in the process; use a core's instead. */
|
|
79
|
+
export const readUsage = usageCounter();
|
|
72
80
|
function stoppedBecause(signal) {
|
|
73
81
|
return new CountStopped(signal.reason instanceof DOMException && signal.reason.name === "TimeoutError"
|
|
74
82
|
? "Counting tool calls took too long and stopped; what was read is kept, so the next count continues."
|
|
@@ -79,7 +87,7 @@ function checkStopped(signal) {
|
|
|
79
87
|
if (signal.aborted)
|
|
80
88
|
throw stoppedBecause(signal);
|
|
81
89
|
}
|
|
82
|
-
async function count(env, dir, rules, signal, maxCachedCalls) {
|
|
90
|
+
async function count(transcripts, env, dir, rules, signal, maxCachedCalls) {
|
|
83
91
|
checkStopped(signal);
|
|
84
92
|
const checkouts = await worktrees(dir);
|
|
85
93
|
// $HOME may end in a slash or be a link, while git prints the resolved path.
|
|
@@ -93,10 +101,7 @@ async function count(env, dir, rules, signal, maxCachedCalls) {
|
|
|
93
101
|
const folders = repository ? checkouts : [dir];
|
|
94
102
|
const projects = join(env.claudeDir, "projects");
|
|
95
103
|
const storeNames = await listDir(projects);
|
|
96
|
-
const
|
|
97
|
-
const calls = [];
|
|
98
|
-
const counted = new Set();
|
|
99
|
-
let sessions = 0;
|
|
104
|
+
const tally = { taken: new Set(), calls: [], sessions: 0 };
|
|
100
105
|
for (const folder of folders) {
|
|
101
106
|
const own = storeDirName(folder);
|
|
102
107
|
// Sessions started in a subfolder or a nested worktree have their own store, named with the
|
|
@@ -107,27 +112,15 @@ async function count(env, dir, rules, signal, maxCachedCalls) {
|
|
|
107
112
|
for (const name of stores) {
|
|
108
113
|
const store = join(projects, name);
|
|
109
114
|
const files = await transcriptsIn(store);
|
|
110
|
-
|
|
111
|
-
if (taken.has(file))
|
|
112
|
-
continue;
|
|
113
|
-
// A subagent's session decides; one whose session file is gone goes by its own start.
|
|
114
|
-
const start = (await transcript(env, session, signal))?.startCwd ??
|
|
115
|
-
(await transcript(env, file, signal))?.startCwd;
|
|
116
|
-
if (start ? !isWithin(folder, start) : name !== own)
|
|
117
|
-
continue;
|
|
118
|
-
taken.add(file);
|
|
119
|
-
counted.add(file);
|
|
120
|
-
calls.push(...((await transcript(env, file, signal))?.calls ?? []));
|
|
121
|
-
if (file === session)
|
|
122
|
-
sessions++;
|
|
123
|
-
}
|
|
115
|
+
await countStore(transcripts, env, signal, tally, folder, files, name === own);
|
|
124
116
|
checkStopped(signal);
|
|
125
117
|
for (const path of transcripts.keys())
|
|
126
118
|
if (path.startsWith(store + sep) && !files.has(path))
|
|
127
119
|
transcripts.delete(path);
|
|
128
120
|
}
|
|
129
121
|
}
|
|
130
|
-
|
|
122
|
+
const { calls, sessions } = tally;
|
|
123
|
+
trimCache(transcripts, tally.taken, maxCachedCalls);
|
|
131
124
|
return {
|
|
132
125
|
sessions,
|
|
133
126
|
repository,
|
|
@@ -137,8 +130,27 @@ async function count(env, dir, rules, signal, maxCachedCalls) {
|
|
|
137
130
|
matches: Object.fromEntries(rules.map((r) => [r.rule, matchCount(r, calls)])),
|
|
138
131
|
};
|
|
139
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Adds the transcripts in one store whose session started in `folder`. A subagent's session
|
|
135
|
+
* decides; one whose session file is gone goes by its own start, or, without one, by whether the
|
|
136
|
+
* store is the folder's own.
|
|
137
|
+
*/
|
|
138
|
+
async function countStore(transcripts, env, signal, tally, folder, files, ownStore) {
|
|
139
|
+
for (const [file, session] of files) {
|
|
140
|
+
if (tally.taken.has(file))
|
|
141
|
+
continue;
|
|
142
|
+
const start = (await transcript(transcripts, env, session, signal))?.startCwd ??
|
|
143
|
+
(await transcript(transcripts, env, file, signal))?.startCwd;
|
|
144
|
+
if (start ? !isWithin(folder, start) : !ownStore)
|
|
145
|
+
continue;
|
|
146
|
+
tally.taken.add(file);
|
|
147
|
+
tally.calls.push(...((await transcript(transcripts, env, file, signal))?.calls ?? []));
|
|
148
|
+
if (file === session)
|
|
149
|
+
tally.sessions++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
140
152
|
/** Drops the least recently counted transcripts over the limit, keeping those just counted. */
|
|
141
|
-
function trimCache(counted, maxCalls) {
|
|
153
|
+
function trimCache(transcripts, counted, maxCalls) {
|
|
142
154
|
let total = 0;
|
|
143
155
|
for (const known of transcripts.values())
|
|
144
156
|
total += known.calls.length;
|
|
@@ -166,7 +178,7 @@ async function transcriptsIn(store) {
|
|
|
166
178
|
* One transcript, read up to its end; one that cannot be read keeps what was read before, and one
|
|
167
179
|
* whose count stops keeps the whole lines read so far.
|
|
168
180
|
*/
|
|
169
|
-
async function transcript(env, file, signal) {
|
|
181
|
+
async function transcript(transcripts, env, file, signal) {
|
|
170
182
|
if (await isCredentials(env, file))
|
|
171
183
|
return null;
|
|
172
184
|
const info = await stat(file).catch(() => null);
|
|
@@ -182,7 +194,7 @@ async function transcript(env, file, signal) {
|
|
|
182
194
|
if (known.size === info.size && known.mtimeMs === info.mtimeMs)
|
|
183
195
|
return known;
|
|
184
196
|
try {
|
|
185
|
-
await readFrom(file, known, info.size, signal);
|
|
197
|
+
await readFrom(env, file, known, info.size, signal);
|
|
186
198
|
known.size = info.size;
|
|
187
199
|
known.mtimeMs = info.mtimeMs;
|
|
188
200
|
}
|
|
@@ -194,8 +206,11 @@ async function transcript(env, file, signal) {
|
|
|
194
206
|
return known;
|
|
195
207
|
}
|
|
196
208
|
/** Reads whole lines from the offset up to `size`; a line still being written is read next time. */
|
|
197
|
-
async function readFrom(file, known, size, signal) {
|
|
198
|
-
|
|
209
|
+
async function readFrom(env, file, known, size, signal) {
|
|
210
|
+
// Checked again on the open file, so one swapped for the credentials file after transcript()'s check is not read.
|
|
211
|
+
const handle = await openUnlessCredentials(env, file);
|
|
212
|
+
if (!handle)
|
|
213
|
+
return;
|
|
199
214
|
try {
|
|
200
215
|
let carried = Buffer.alloc(0);
|
|
201
216
|
let position = known.offset;
|
|
@@ -220,10 +235,10 @@ async function readFrom(file, known, size, signal) {
|
|
|
220
235
|
}
|
|
221
236
|
}
|
|
222
237
|
/** Takes the tool calls from an assistant line and your answers from a tool result line. */
|
|
223
|
-
function read(line,
|
|
238
|
+
function read(line, into) {
|
|
224
239
|
const hasCall = line.includes('"tool_use"');
|
|
225
240
|
const hasDecision = line.includes('"permissionDecision"');
|
|
226
|
-
const hasStart = !
|
|
241
|
+
const hasStart = !into.startCwd && line.includes('"cwd"');
|
|
227
242
|
if (!hasCall && !hasDecision && !hasStart)
|
|
228
243
|
return;
|
|
229
244
|
let record;
|
|
@@ -235,35 +250,42 @@ function read(line, transcript) {
|
|
|
235
250
|
}
|
|
236
251
|
if (!isRecord(record))
|
|
237
252
|
return;
|
|
238
|
-
if (!
|
|
239
|
-
|
|
253
|
+
if (!into.startCwd && typeof record.cwd === "string")
|
|
254
|
+
into.startCwd = record.cwd;
|
|
240
255
|
if (!isRecord(record.message))
|
|
241
256
|
return;
|
|
242
257
|
const content = record.message.content;
|
|
243
258
|
if (!Array.isArray(content))
|
|
244
259
|
return;
|
|
260
|
+
if (record.type === "assistant")
|
|
261
|
+
readCalls(record, content, into);
|
|
262
|
+
else if (record.type === "user")
|
|
263
|
+
readDecision(record, content, into);
|
|
264
|
+
}
|
|
265
|
+
/** The tool calls in an assistant line. */
|
|
266
|
+
function readCalls(record, content, into) {
|
|
245
267
|
const at = typeof record.timestamp === "string" ? record.timestamp : "";
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
transcript.byId.set(block.id, call);
|
|
255
|
-
}
|
|
256
|
-
return;
|
|
268
|
+
for (const block of content) {
|
|
269
|
+
if (!isRecord(block) || block.type !== "tool_use")
|
|
270
|
+
continue;
|
|
271
|
+
if (typeof block.id !== "string" || typeof block.name !== "string")
|
|
272
|
+
continue;
|
|
273
|
+
const call = callOf(block.name, isRecord(block.input) ? block.input : {}, at);
|
|
274
|
+
into.calls.push(call);
|
|
275
|
+
into.byId.set(block.id, call);
|
|
257
276
|
}
|
|
277
|
+
}
|
|
278
|
+
/** Your answer to the calls a tool result line answers, when you gave it by hand. */
|
|
279
|
+
function readDecision(record, content, into) {
|
|
258
280
|
const decision = record.permissionDecision;
|
|
259
|
-
if (
|
|
281
|
+
if (!isRecord(decision))
|
|
260
282
|
return;
|
|
261
283
|
if (typeof decision.source !== "string" || !decision.source.startsWith("user"))
|
|
262
284
|
return;
|
|
263
285
|
for (const block of content) {
|
|
264
286
|
if (!isRecord(block) || block.type !== "tool_result")
|
|
265
287
|
continue;
|
|
266
|
-
const call = typeof block.tool_use_id === "string" &&
|
|
288
|
+
const call = typeof block.tool_use_id === "string" && into.byId.get(block.tool_use_id);
|
|
267
289
|
if (call)
|
|
268
290
|
call.byYou = decision.decision === "accept";
|
|
269
291
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Validator } from "typebox/compile";
|
|
2
|
+
/**
|
|
3
|
+
* What `validator` found wrong with `value`, each led by where: `label` and the path inside it,
|
|
4
|
+
* such as `sandbox.network`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function schemaErrors(validator: Validator, value: unknown, label: string): string[];
|
|
7
|
+
/** The first thing wrong with a request body, such as "add.list must be string". */
|
|
8
|
+
export declare function firstError(validator: Validator, value: unknown): string;
|