@falkenslab/agent-kit 0.17.0 → 0.19.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 (111) hide show
  1. package/assets/extension-launcher.mjs +24 -0
  2. package/dist/chat/chatController.d.ts +264 -0
  3. package/dist/chat/chatController.js +510 -0
  4. package/dist/chat/extensions.d.ts +14 -0
  5. package/dist/chat/extensions.js +41 -0
  6. package/dist/chat/history.d.ts +17 -0
  7. package/dist/chat/history.js +39 -0
  8. package/dist/{tui → chat}/runs.d.ts +8 -1
  9. package/dist/{tui → chat}/runs.js +1 -1
  10. package/dist/core/agentSpec.d.ts +41 -27
  11. package/dist/core/extensions.d.ts +119 -0
  12. package/dist/core/extensions.js +97 -0
  13. package/dist/core/externalExtensions.d.ts +131 -0
  14. package/dist/core/externalExtensions.js +340 -0
  15. package/dist/core/hooks/fileScopeGate.d.ts +17 -2
  16. package/dist/core/hooks/fileScopeGate.js +45 -10
  17. package/dist/core/hooks/planGate.d.ts +2 -0
  18. package/dist/core/hooks/planGate.js +1 -10
  19. package/dist/core/hooks/stepGate.d.ts +1 -1
  20. package/dist/core/hooks/stepGate.js +3 -3
  21. package/dist/core/language.js +1 -1
  22. package/dist/core/messages/de.js +12 -33
  23. package/dist/core/messages/en.d.ts +13 -16
  24. package/dist/core/messages/en.js +12 -33
  25. package/dist/core/messages/es.js +12 -33
  26. package/dist/core/messages/fr.js +12 -33
  27. package/dist/core/messages/index.d.ts +6 -0
  28. package/dist/core/messages/index.js +12 -1
  29. package/dist/core/packaged.d.ts +10 -0
  30. package/dist/core/packaged.js +38 -0
  31. package/dist/core/pluginAgents.d.ts +20 -0
  32. package/dist/core/pluginAgents.js +76 -0
  33. package/dist/core/runner.js +16 -9
  34. package/dist/core/session.d.ts +42 -5
  35. package/dist/core/session.js +203 -99
  36. package/dist/core/sessionFacts.d.ts +80 -0
  37. package/dist/core/sessionFacts.js +48 -0
  38. package/dist/core/toolLabels.d.ts +24 -6
  39. package/dist/core/toolLabels.js +24 -39
  40. package/dist/core/tools/humanApproval.js +3 -3
  41. package/dist/extensions/awareness/index.d.ts +13 -0
  42. package/dist/extensions/awareness/index.js +39 -0
  43. package/dist/extensions/awareness/labels.d.ts +3 -0
  44. package/dist/extensions/awareness/labels.js +12 -0
  45. package/dist/extensions/awareness/tools.d.ts +5 -0
  46. package/dist/extensions/awareness/tools.js +54 -0
  47. package/dist/{core → extensions/knowledge}/fileKnowledgeStore.d.ts +1 -3
  48. package/dist/{core → extensions/knowledge}/fileKnowledgeStore.js +23 -29
  49. package/dist/extensions/knowledge/index.d.ts +8 -0
  50. package/dist/extensions/knowledge/index.js +35 -0
  51. package/dist/{core → extensions/knowledge}/knowledgeStore.d.ts +5 -9
  52. package/dist/{core → extensions/knowledge}/knowledgeStore.js +16 -2
  53. package/dist/extensions/knowledge/labels.d.ts +3 -0
  54. package/dist/extensions/knowledge/labels.js +119 -0
  55. package/dist/extensions/knowledge/prompt.d.ts +28 -0
  56. package/dist/extensions/knowledge/prompt.js +59 -0
  57. package/dist/{core/tools/knowledgeTools.d.ts → extensions/knowledge/tools.d.ts} +7 -7
  58. package/dist/{core/tools/knowledgeTools.js → extensions/knowledge/tools.js} +20 -55
  59. package/dist/extensions/memory/index.d.ts +12 -0
  60. package/dist/extensions/memory/index.js +56 -0
  61. package/dist/extensions/memory/labels.d.ts +3 -0
  62. package/dist/extensions/memory/labels.js +58 -0
  63. package/dist/extensions/memory/memoryStore.d.ts +53 -0
  64. package/dist/extensions/memory/memoryStore.js +88 -0
  65. package/dist/extensions/memory/tools.d.ts +9 -0
  66. package/dist/extensions/memory/tools.js +82 -0
  67. package/dist/extensions/sources/index.d.ts +9 -0
  68. package/dist/extensions/sources/index.js +40 -0
  69. package/dist/extensions/sources/labels.d.ts +3 -0
  70. package/dist/extensions/sources/labels.js +82 -0
  71. package/dist/{core → extensions/sources}/sources.d.ts +24 -31
  72. package/dist/{core → extensions/sources}/sources.js +45 -108
  73. package/dist/{core/tools/saveToSources.d.ts → extensions/sources/tools.d.ts} +7 -3
  74. package/dist/{core/tools/saveToSources.js → extensions/sources/tools.js} +22 -17
  75. package/dist/index.d.ts +14 -9
  76. package/dist/index.js +11 -7
  77. package/dist/tui/chatTui.d.ts +11 -20
  78. package/dist/tui/chatTui.js +75 -230
  79. package/dist/tui/extensionCommand.d.ts +21 -0
  80. package/dist/tui/extensionCommand.js +133 -0
  81. package/dist/tui/ink/runChatInk.d.ts +4 -3
  82. package/dist/tui/ink/runChatInk.js +101 -217
  83. package/extensions/awareness/.claude-plugin/plugin.json +10 -0
  84. package/{assets/agent-help → extensions/awareness/skills/help}/SKILL.md +4 -3
  85. package/{assets/knowledge-plugin → extensions/knowledge}/.claude-plugin/plugin.json +7 -1
  86. package/extensions/knowledge/commands/ingest.md +7 -0
  87. package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-ingest/SKILL.md +8 -3
  88. package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-lint/SKILL.md +12 -5
  89. package/extensions/memory/.claude-plugin/plugin.json +10 -0
  90. package/extensions/memory/commands/forget.md +8 -0
  91. package/extensions/memory/commands/list.md +5 -0
  92. package/extensions/sources/.claude-plugin/plugin.json +10 -0
  93. package/package.json +3 -2
  94. package/assets/knowledge-plugin/commands/ingest.md +0 -7
  95. package/assets/knowledge-plugin/commands/query.md +0 -5
  96. package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +0 -4
  97. package/assets/knowledge-plugin-files/commands/ingest.md +0 -7
  98. package/assets/knowledge-plugin-files/commands/lint.md +0 -5
  99. package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +0 -46
  100. package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +0 -26
  101. package/assets/knowledge-plugin-files/skills/knowledge-pages/SKILL.md +0 -137
  102. package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +0 -14
  103. package/dist/core/agentHelp.d.ts +0 -30
  104. package/dist/core/agentHelp.js +0 -55
  105. package/dist/core/knowledge.d.ts +0 -25
  106. package/dist/core/knowledge.js +0 -84
  107. /package/dist/{core → extensions/sources}/extractText.d.ts +0 -0
  108. /package/dist/{core → extensions/sources}/extractText.js +0 -0
  109. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/lint.md +0 -0
  110. /package/{assets/knowledge-plugin-files → extensions/knowledge}/commands/query.md +0 -0
  111. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-query/SKILL.md +0 -0
@@ -0,0 +1,340 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { cp, mkdtemp, readdir, readFile, rm, stat, writeFile, mkdir } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { promisify } from "node:util";
8
+ import { getLanguage } from "./messages/index.js";
9
+ import { truncate } from "./toolLabels.js";
10
+ import { agentKitVersion } from "./version.js";
11
+ import { outsideArchive } from "./packaged.js";
12
+ export const LOCK_FILE = "extensions.lock.json";
13
+ /** A scope's lock: its extensions by name. */
14
+ export async function readLock(scopeDir) {
15
+ try {
16
+ return JSON.parse(await readFile(path.join(scopeDir, LOCK_FILE), "utf8"));
17
+ }
18
+ catch (error) {
19
+ if (error.code === "ENOENT")
20
+ return {};
21
+ throw error;
22
+ }
23
+ }
24
+ async function writeLock(scopeDir, lock) {
25
+ await mkdir(scopeDir, { recursive: true });
26
+ const sorted = Object.fromEntries(Object.keys(lock).sort().map((name) => [name, lock[name]]));
27
+ await writeFile(path.join(scopeDir, LOCK_FILE), `${JSON.stringify(sorted, null, 2)}\n`, "utf8");
28
+ }
29
+ /** Reads an extension's manifest, checking what the kit needs from it. */
30
+ export async function readExternalManifest(dir) {
31
+ const file = path.join(dir, ".claude-plugin", "plugin.json");
32
+ let raw;
33
+ try {
34
+ raw = JSON.parse(await readFile(file, "utf8"));
35
+ }
36
+ catch {
37
+ throw new Error(`${dir} isn't an extension: it has no readable .claude-plugin/plugin.json.`);
38
+ }
39
+ const name = raw.name;
40
+ if (typeof name !== "string" || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name))
41
+ throw new Error(`${file}: "name" must be kebab-case.`);
42
+ const kit = (raw["agent-kit"] ?? {});
43
+ const text = (key) => (typeof raw[key] === "string" ? { [key]: raw[key] } : {});
44
+ const author = raw.author;
45
+ // `repository` may be a URL or `{ url }`, as in package.json.
46
+ const repository = typeof raw.repository === "string" ? raw.repository : raw.repository?.url;
47
+ return {
48
+ name,
49
+ description: typeof raw.description === "string" ? raw.description : "",
50
+ ...text("version"),
51
+ ...(author && typeof author === "object" && typeof author.name === "string"
52
+ ? { author: { name: author.name, ...(author.email ? { email: author.email } : {}), ...(author.url ? { url: author.url } : {}) } }
53
+ : {}),
54
+ ...text("homepage"),
55
+ ...(typeof repository === "string" ? { repository } : {}),
56
+ ...text("license"),
57
+ ...(Array.isArray(raw.keywords) ? { keywords: raw.keywords.filter((keyword) => typeof keyword === "string") } : {}),
58
+ provides: kit.provides ?? [],
59
+ requires: kit.requires ?? [],
60
+ ...(kit.kit ? { kit: kit.kit } : {}),
61
+ servers: await pluginServers(dir, raw),
62
+ readOnlyTools: kit.readOnlyTools ?? [],
63
+ labels: kit.labels ?? {},
64
+ ...(kit.help ? { help: kit.help } : {}),
65
+ };
66
+ }
67
+ /**
68
+ * A plugin's MCP servers, where Claude Code looks for them: `mcpServers` in `plugin.json` (inline,
69
+ * or a path to a JSON file), else `.mcp.json` at its root, with or without the `mcpServers` key.
70
+ */
71
+ async function pluginServers(dir, raw) {
72
+ const read = async (file) => {
73
+ try {
74
+ return JSON.parse(await readFile(path.join(dir, file), "utf8"));
75
+ }
76
+ catch (error) {
77
+ if (error.code === "ENOENT")
78
+ return undefined;
79
+ throw new Error(`${file} can't be read as JSON.`, { cause: error });
80
+ }
81
+ };
82
+ let declared = raw.mcpServers;
83
+ if (typeof declared === "string")
84
+ declared = await read(declared);
85
+ if (declared === undefined)
86
+ declared = await read(".mcp.json");
87
+ if (declared && typeof declared === "object" && "mcpServers" in declared)
88
+ declared = declared.mcpServers;
89
+ if (declared === undefined || declared === null)
90
+ return {};
91
+ if (typeof declared !== "object")
92
+ throw new Error("its MCP servers aren't a JSON object.");
93
+ const servers = {};
94
+ for (const [name, server] of Object.entries(declared)) {
95
+ if (!server || typeof server.command !== "string")
96
+ throw new Error(`its MCP server "${name}" has no command.`);
97
+ servers[name] = { command: server.command, ...(Array.isArray(server.args) ? { args: server.args.map(String) } : {}), ...(server.env ? { env: server.env } : {}) };
98
+ }
99
+ return servers;
100
+ }
101
+ /** `${CLAUDE_PLUGIN_ROOT}` (the extension's folder) and `${VAR}` (the agent's variable) in a server's values. */
102
+ function expand(value, root) {
103
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => (name === "CLAUDE_PLUGIN_ROOT" ? root : (process.env[name] ?? "")));
104
+ }
105
+ /**
106
+ * Why one of an extension's servers can't run through the launcher (only Node, its script
107
+ * first, inside the extension), or how the kit starts it.
108
+ */
109
+ function serverLaunch(name, server, root) {
110
+ if (!/^node(\.exe)?$/i.test(path.basename(server.command)))
111
+ return `runs its server "${name}" with "${server.command}": only Node servers can be installed`;
112
+ const [script, ...args] = (server.args ?? []).map((arg) => expand(arg, root));
113
+ if (!script)
114
+ return `gives its server "${name}" no script to run`;
115
+ const entry = path.resolve(root, script);
116
+ if (path.relative(root, entry).startsWith(".."))
117
+ return `runs its server "${name}" from outside its folder (${script})`;
118
+ return { entry, args, env: Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, expand(String(value), root)])) };
119
+ }
120
+ /** Why an extension's servers can't run, or nothing: each must be Node, with its script there. */
121
+ async function serversProblem(manifest, root) {
122
+ for (const [name, server] of Object.entries(manifest.servers)) {
123
+ const launch = serverLaunch(name, server, root);
124
+ if (typeof launch === "string")
125
+ return launch;
126
+ if (!(await stat(launch.entry).catch(() => null))?.isFile())
127
+ return `has no script at ${path.relative(root, launch.entry)} for its server "${name}"`;
128
+ }
129
+ return undefined;
130
+ }
131
+ /** SHA-256 over an extension's files (their paths and contents, in order), `.git` aside. */
132
+ export async function hashExtension(dir) {
133
+ const hash = createHash("sha256");
134
+ async function walk(relative) {
135
+ const entries = (await readdir(path.join(dir, relative), { withFileTypes: true })).filter((entry) => entry.name !== ".git");
136
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
137
+ const child = relative ? `${relative}/${entry.name}` : entry.name;
138
+ if (entry.isDirectory())
139
+ await walk(child);
140
+ else if (entry.isFile())
141
+ hash.update(`${child}\0`).update(await readFile(path.join(dir, child))).update("\0");
142
+ }
143
+ }
144
+ await walk("");
145
+ return hash.digest("hex");
146
+ }
147
+ /**
148
+ * Whether `version` (x.y.z) is in `range`: comparators separated by spaces, all of which must
149
+ * hold (`>=0.19.0 <0.21.0`, `=0.19.2`, `0.19.2`).
150
+ */
151
+ export function inKitRange(version, range) {
152
+ const parse = (text) => text.split("-")[0].split(".").map((part) => Number(part) || 0);
153
+ const compare = (a, b) => {
154
+ for (let i = 0; i < 3; i++)
155
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
156
+ return (a[i] ?? 0) - (b[i] ?? 0);
157
+ return 0;
158
+ };
159
+ const current = parse(version);
160
+ return range
161
+ .trim()
162
+ .split(/\s+/)
163
+ .every((comparator) => {
164
+ const match = /^(>=|<=|>|<|=)?\s*v?(\d+(?:\.\d+){0,2})$/.exec(comparator);
165
+ if (!match)
166
+ return false;
167
+ const order = compare(current, parse(match[2]));
168
+ switch (match[1]) {
169
+ case ">=":
170
+ return order >= 0;
171
+ case "<=":
172
+ return order <= 0;
173
+ case ">":
174
+ return order > 0;
175
+ case "<":
176
+ return order < 0;
177
+ default:
178
+ return order === 0;
179
+ }
180
+ });
181
+ }
182
+ /** Every extension installed in the scopes, the project's first. */
183
+ export async function listInstalled(dirs) {
184
+ const installed = [];
185
+ for (const scope of ["project", "agent"]) {
186
+ const scopeDir = dirs[scope];
187
+ if (!scopeDir)
188
+ continue;
189
+ for (const [name, lock] of Object.entries(await readLock(scopeDir))) {
190
+ const dir = path.join(scopeDir, name);
191
+ const manifest = await readExternalManifest(dir).catch(() => undefined);
192
+ installed.push({ name, scope, dir, lock, shadowed: installed.some((other) => other.name === name), ...(manifest ? { manifest } : {}) });
193
+ }
194
+ }
195
+ return installed;
196
+ }
197
+ /** The launcher that starts an external extension's server with a clean environment. */
198
+ export function extensionLauncherPath() {
199
+ // Node runs it as another process: outside the archive when packaged (#42).
200
+ return outsideArchive(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "extension-launcher.mjs"));
201
+ }
202
+ /** Its tools' chat labels in the kit's language, from its manifest: `{field}` takes the input; for each of its servers. */
203
+ function manifestToolLabels(manifest) {
204
+ const language = getLanguage();
205
+ const fill = (template, input) => template.replace(/\{(\w+)\}/g, (_, field) => (input[field] === undefined ? "" : truncate(String(input[field]), 60)));
206
+ return Object.fromEntries(Object.entries(manifest.labels).flatMap(([tool, byLanguage]) => {
207
+ const texts = byLanguage[language] ?? byLanguage.en;
208
+ if (!texts)
209
+ return [];
210
+ const label = { label: (input) => fill(texts.label, input), ...(texts.phrase ? { phrase: texts.phrase } : {}) };
211
+ return Object.keys(manifest.servers).map((server) => [`mcp__${server}__${tool}`, label]);
212
+ }));
213
+ }
214
+ /** The extension an installed one makes: its servers started through the launcher, its rules from its manifest. */
215
+ function externalExtension(installed, manifest) {
216
+ return {
217
+ name: manifest.name,
218
+ plugin: installed.dir,
219
+ external: true,
220
+ async contribute() {
221
+ const servers = {};
222
+ for (const [name, server] of Object.entries(manifest.servers)) {
223
+ const launch = serverLaunch(name, server, installed.dir);
224
+ if (typeof launch === "string")
225
+ continue; // checked when it loaded
226
+ // Its `env` reaches it (the SDK merges it with the agent's); the launcher removes the rest.
227
+ // Inside Electron, process.execPath is the app itself: ELECTRON_RUN_AS_NODE makes it run
228
+ // the launcher as Node (the launcher removes it, with the rest, once it runs).
229
+ const env = process.versions.electron ? { ...launch.env, ELECTRON_RUN_AS_NODE: "1" } : launch.env;
230
+ servers[name] = { type: "stdio", command: process.execPath, args: [extensionLauncherPath(), launch.entry, JSON.stringify(Object.keys(launch.env)), ...launch.args], env };
231
+ }
232
+ return {
233
+ mcpServers: servers,
234
+ readOnlyTools: Object.keys(manifest.servers).flatMap((server) => manifest.readOnlyTools.map((tool) => `mcp__${server}__${tool}`)),
235
+ toolLabels: manifestToolLabels(manifest),
236
+ ...(manifest.help ? { helpLines: [manifest.help] } : {}),
237
+ };
238
+ },
239
+ };
240
+ }
241
+ /** The installed extensions a session runs with, and those it can't (with why). Disabled ones are neither. */
242
+ export async function loadExternalExtensions(dirs) {
243
+ const extensions = [];
244
+ const off = [];
245
+ for (const installed of await listInstalled(dirs)) {
246
+ if (installed.shadowed || !installed.lock.enabled)
247
+ continue;
248
+ const reason = await problem(installed);
249
+ if (typeof reason === "string")
250
+ off.push({ name: installed.name, reason });
251
+ else
252
+ extensions.push(externalExtension(installed, reason));
253
+ }
254
+ return { extensions, off };
255
+ }
256
+ /** Why an installed extension can't load, or its manifest when it can. */
257
+ async function problem(installed) {
258
+ let manifest;
259
+ try {
260
+ if ((await hashExtension(installed.dir)) !== installed.lock.sha256)
261
+ return "has files that changed since it was installed: install it again to trust them";
262
+ manifest = await readExternalManifest(installed.dir);
263
+ }
264
+ catch (error) {
265
+ return `can't be read (${error instanceof Error ? error.message : String(error)})`;
266
+ }
267
+ if (manifest.name !== installed.name)
268
+ return `is installed as "${installed.name}" but its manifest says "${manifest.name}"`;
269
+ if (manifest.kit && !inKitRange(agentKitVersion(), manifest.kit))
270
+ return `works with agent-kit ${manifest.kit}, and this agent runs ${agentKitVersion()}`;
271
+ return (await serversProblem(manifest, installed.dir)) ?? manifest;
272
+ }
273
+ const exec = promisify(execFile);
274
+ /** Whether `source` names a git repository rather than a folder. */
275
+ export function isGitSource(source) {
276
+ return /^(https?:\/\/|git@|ssh:\/\/|git:\/\/|file:\/\/|github:)/.test(source) || /\.git(#.*)?$/.test(source);
277
+ }
278
+ /**
279
+ * Installs an extension into a scope from a folder or a git repository (`url#ref`, and `subdir`
280
+ * for one inside it): copies its files (never runs a script), checks its manifest, and locks it,
281
+ * enabled. An extension with the same name in that scope is replaced.
282
+ */
283
+ export async function addExtension(source, scopeDir, options = {}) {
284
+ let from = path.resolve(source);
285
+ let commit;
286
+ let clone;
287
+ if (isGitSource(source)) {
288
+ const [url, ref] = source.replace(/^github:/, "https://github.com/").split("#");
289
+ clone = await mkdtemp(path.join(os.tmpdir(), "agent-kit-extension-"));
290
+ await exec("git", ["clone", "--depth", "1", ...(ref ? ["--branch", ref] : []), url, clone]);
291
+ commit = (await exec("git", ["-C", clone, "rev-parse", "HEAD"])).stdout.trim();
292
+ from = clone;
293
+ }
294
+ try {
295
+ if (options.subdir)
296
+ from = path.join(from, options.subdir);
297
+ const manifest = await readExternalManifest(from);
298
+ const servers = await serversProblem(manifest, from);
299
+ if (servers)
300
+ throw new Error(`${manifest.name} can't be installed: it ${servers}.`);
301
+ const target = path.join(scopeDir, manifest.name);
302
+ const lock = await readLock(scopeDir);
303
+ const replaced = Boolean(lock[manifest.name]);
304
+ await rm(target, { recursive: true, force: true });
305
+ await mkdir(scopeDir, { recursive: true });
306
+ await cp(from, target, { recursive: true, filter: (file) => path.basename(file) !== ".git" });
307
+ lock[manifest.name] = {
308
+ source: isGitSource(source) ? `${source}${options.subdir ? ` (${options.subdir})` : ""}` : from,
309
+ ...(commit ? { commit } : {}),
310
+ sha256: await hashExtension(target),
311
+ enabled: true,
312
+ installed: new Date().toISOString(),
313
+ };
314
+ await writeLock(scopeDir, lock);
315
+ return { name: manifest.name, replaced, ...(commit ? { commit } : {}) };
316
+ }
317
+ finally {
318
+ if (clone)
319
+ await rm(clone, { recursive: true, force: true });
320
+ }
321
+ }
322
+ /** Removes an extension from a scope (its files and its lock entry); false when it wasn't there. */
323
+ export async function removeExtension(scopeDir, name) {
324
+ const lock = await readLock(scopeDir);
325
+ if (!lock[name])
326
+ return false;
327
+ delete lock[name];
328
+ await rm(path.join(scopeDir, name), { recursive: true, force: true });
329
+ await writeLock(scopeDir, lock);
330
+ return true;
331
+ }
332
+ /** Enables or disables an installed extension in a scope; false when it isn't there. */
333
+ export async function setExtensionEnabled(scopeDir, name, enabled) {
334
+ const lock = await readLock(scopeDir);
335
+ if (!lock[name])
336
+ return false;
337
+ lock[name] = { ...lock[name], enabled };
338
+ await writeLock(scopeDir, lock);
339
+ return true;
340
+ }
@@ -24,17 +24,32 @@ export interface FileScope {
24
24
  dir: string;
25
25
  instead: string;
26
26
  }[];
27
+ /**
28
+ * With it, `Read` and `Glob` are only allowed inside these (an allow-list, like
29
+ * `writableDirs` and `searchableDirs`), plus `alsoReadable` and the tool results under
30
+ * `toolResultsRoot`; `deniedPaths` still wins. Without it, they reach any path but the
31
+ * denied ones: any file the user can read, `~/.ssh` included.
32
+ */
33
+ readableDirs?: string[];
34
+ /** Readable too with `readableDirs`, but not named in a denial: the run folder, the plugin roots. */
35
+ alsoReadable?: string[];
36
+ /**
37
+ * The CLI's folder for this project (`~/.claude/projects/<project>`): with `readableDirs`,
38
+ * its sessions' `tool-results/` folders, where the SDK leaves large tool outputs for the
39
+ * agent to `Read`, are readable; nothing else in it.
40
+ */
41
+ toolResultsRoot?: string;
27
42
  }
28
43
  /** Whether `candidate` is `base` itself or somewhere inside it (case-insensitive on Windows, like path.relative). */
29
44
  export declare function isWithin(base: string, candidate: string): boolean;
30
45
  /**
31
46
  * The reason to deny `toolName` with `input` under `scope`, or `undefined` to let it
32
- * through. Tools other than Read/Write/Edit/Grep are never judged here, and a call
47
+ * through. Tools other than Read/Write/Edit/Grep/Glob are never judged here, and a call
33
48
  * without a path is left for the tool itself to reject.
34
49
  */
35
50
  export declare function checkFileScope(scope: FileScope, toolName: string, input: Record<string, unknown>): string | undefined;
36
51
  /**
37
- * PreToolUse hook enforcing `scope` on Read/Write/Edit/Grep, for the main agent and its
52
+ * PreToolUse hook enforcing `scope` on Read/Write/Edit/Grep/Glob, for the main agent and its
38
53
  * subagents alike. Bash, when a subagent has it, is not covered: it can reach any path,
39
54
  * which is why Bash-granting features stay opt-in.
40
55
  */
@@ -5,11 +5,33 @@ export function isWithin(base, candidate) {
5
5
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
6
6
  }
7
7
  function describeDirs(scope, dirs) {
8
- return dirs.map((dir) => `${path.relative(scope.projectDir, dir) || "."}/`).join(", ");
8
+ return dirs.map((dir) => (isWithin(scope.projectDir, dir) ? `${path.relative(scope.projectDir, dir) || "."}/` : `${dir}/`)).join(", ");
9
+ }
10
+ /** Where a `Glob` really starts: its path plus the pattern's fixed folders ("sources/**" starts in sources/). */
11
+ function globStart(scope, input) {
12
+ const target = typeof input.path === "string" && input.path !== "" ? path.resolve(scope.projectDir, input.path) : scope.projectDir;
13
+ const pattern = typeof input.pattern === "string" ? input.pattern : "";
14
+ return path.resolve(target, pattern.replace(/[^/\\]*[*?[{].*$/, ""));
15
+ }
16
+ /** Whether `target` is readable under a scope with `readableDirs`. */
17
+ function isReadable(scope, target) {
18
+ const dirs = [...(scope.readableDirs ?? []), ...(scope.alsoReadable ?? [])].map((dir) => path.resolve(scope.projectDir, dir));
19
+ if (dirs.some((dir) => isWithin(dir, target)))
20
+ return true;
21
+ if (!scope.toolResultsRoot || !isWithin(scope.toolResultsRoot, target))
22
+ return false;
23
+ // <root>/<session>/tool-results/…, and nothing else of the CLI's (transcripts, other files).
24
+ return path.relative(scope.toolResultsRoot, target).split(path.sep)[1] === "tool-results";
25
+ }
26
+ /** The denial for reading or listing outside `readableDirs`. */
27
+ function notReadable(scope, shown, tool) {
28
+ const dirs = scope.readableDirs ?? [];
29
+ const what = tool === "Glob" ? `Glob can't list "${String(shown)}": listing` : `"${String(shown)}" can't be read: reading`;
30
+ return `${what} is only allowed inside ${dirs.length ? `${describeDirs(scope, dirs)} and ` : ""}your own plugins and run folder.`;
9
31
  }
10
32
  /**
11
33
  * The reason to deny `toolName` with `input` under `scope`, or `undefined` to let it
12
- * through. Tools other than Read/Write/Edit/Grep are never judged here, and a call
34
+ * through. Tools other than Read/Write/Edit/Grep/Glob are never judged here, and a call
13
35
  * without a path is left for the tool itself to reject.
14
36
  */
15
37
  export function checkFileScope(scope, toolName, input) {
@@ -26,19 +48,32 @@ export function checkFileScope(scope, toolName, input) {
26
48
  return viaTools(input.file_path, only);
27
49
  }
28
50
  if (toolName === "Grep" || toolName === "Glob") {
29
- const target = resolve(input.path) ?? scope.projectDir;
30
- const pattern = typeof input.pattern === "string" ? input.pattern : "";
31
- // Where a search really starts: Grep's path, Glob's path plus the pattern's fixed folders
32
- // ("sources/**" starts in sources/). From above the folder it would reach into it.
33
- const start = toolName === "Glob" ? path.resolve(target, pattern.replace(/[^/\\]*[*?[{].*$/, "")) : target;
51
+ // Where a search really starts: Grep's path, Glob's path plus the pattern's fixed folders.
52
+ // From above the folder it would reach into it.
53
+ const start = toolName === "Glob" ? globStart(scope, input) : (resolve(input.path) ?? scope.projectDir);
34
54
  const only = toolOnly(start) ?? toolOnlyDirs.find(({ dir }) => isWithin(start, dir));
35
55
  if (only)
36
- return viaTools(input.path ?? pattern, only);
56
+ return viaTools(input.path ?? input.pattern ?? "", only);
37
57
  }
38
58
  switch (toolName) {
39
59
  case "Read": {
40
60
  const target = resolve(input.file_path);
41
- return target && isDenied(target) ? `"${input.file_path}" is off limits: it can't be read.` : undefined;
61
+ if (!target)
62
+ return undefined;
63
+ if (isDenied(target))
64
+ return `"${input.file_path}" is off limits: it can't be read.`;
65
+ return scope.readableDirs && !isReadable(scope, target) ? notReadable(scope, input.file_path, "Read") : undefined;
66
+ }
67
+ case "Glob": {
68
+ const start = globStart(scope, input);
69
+ const shown = input.path ?? input.pattern;
70
+ if (isDenied(start))
71
+ return `"${String(shown)}" is off limits: it can't be listed.`;
72
+ if (!scope.readableDirs)
73
+ return undefined;
74
+ // ".." in the pattern ("**/../../x") would climb out of where the search starts.
75
+ const climbs = typeof input.pattern === "string" && input.pattern.split(/[/\\]/).includes("..");
76
+ return climbs || !isReadable(scope, start) ? notReadable(scope, shown, "Glob") : undefined;
42
77
  }
43
78
  case "Write":
44
79
  case "Edit": {
@@ -66,7 +101,7 @@ export function checkFileScope(scope, toolName, input) {
66
101
  }
67
102
  }
68
103
  /**
69
- * PreToolUse hook enforcing `scope` on Read/Write/Edit/Grep, for the main agent and its
104
+ * PreToolUse hook enforcing `scope` on Read/Write/Edit/Grep/Glob, for the main agent and its
70
105
  * subagents alike. Bash, when a subagent has it, is not covered: it can reach any path,
71
106
  * which is why Bash-granting features stay opt-in.
72
107
  */
@@ -7,6 +7,8 @@ export interface PlanScope {
7
7
  isPlanFile?(filePath: string): boolean;
8
8
  /** Whether one of the agent's own MCP tools only reads. */
9
9
  isReadOnlyTool?(toolName: string, input: Record<string, unknown>): boolean;
10
+ /** The enabled extensions' MCP tools that only read (`mcp__<server>__<tool>`). */
11
+ readOnlyTools?: readonly string[];
10
12
  }
11
13
  /**
12
14
  * The reason to deny `toolName` with `input` in plan mode, or `undefined` to let it through.
@@ -3,15 +3,6 @@ import path from "node:path";
3
3
  const READING_TOOLS = new Set(["Read", "Glob", "Grep", "WebFetch", "WebSearch", "Skill", "Agent", "TodoWrite"]);
4
4
  /** The kit's own MCP servers whose tools only ask a human (approvals, manual intervention) or only read (the date and time). */
5
5
  const ASKING_SERVERS = ["mcp__approvals__", "mcp__manualLogin__", "mcp__time__"];
6
- /** The kit's own MCP tools that only read, on servers that also have tools that write. */
7
- const READING_KIT_TOOLS = new Set([
8
- "mcp__sourceFiles__list_sources",
9
- "mcp__sourceFiles__extract_text",
10
- "mcp__knowledge__knowledge_index",
11
- "mcp__knowledge__knowledge_search",
12
- "mcp__knowledge__knowledge_read",
13
- "mcp__knowledge__knowledge_check",
14
- ]);
15
6
  /** Why a call is denied in plan mode, and what the model should do instead. */
16
7
  const denial = "Plan mode: nothing can be changed until the user leaves plan mode. Keep reading and researching, then present the plan; the user switches out of plan mode when they want it carried out.";
17
8
  /**
@@ -22,7 +13,7 @@ const denial = "Plan mode: nothing can be changed until the user leaves plan mod
22
13
  export function checkPlanScope(scope, toolName, input) {
23
14
  if (READING_TOOLS.has(toolName))
24
15
  return undefined;
25
- if (ASKING_SERVERS.some((prefix) => toolName.startsWith(prefix)) || READING_KIT_TOOLS.has(toolName))
16
+ if (ASKING_SERVERS.some((prefix) => toolName.startsWith(prefix)) || scope.readOnlyTools?.includes(toolName))
26
17
  return undefined;
27
18
  if (toolName === "Write" || toolName === "Edit") {
28
19
  const target = typeof input.file_path === "string" && input.file_path !== "" ? path.resolve(scope.projectDir, input.file_path) : undefined;
@@ -8,4 +8,4 @@ import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
8
8
  * session.ts's `ModeControl`): the hook stays registered and, while inactive, gives no
9
9
  * decision at all, so the tool call goes on exactly as if the hook weren't there.
10
10
  */
11
- export declare function createStepGate(runDir: string, isActive?: () => boolean): HookCallback;
11
+ export declare function createStepGate(runDir: string, isActive?: () => boolean, selfAskingTools?: readonly string[]): HookCallback;
@@ -1,7 +1,7 @@
1
1
  import { askForDecision } from "./humanInput.js";
2
2
  import { t } from "../messages/index.js";
3
3
  const SELF_ASKING_SERVERS = ["mcp__approvals__", "mcp__manualLogin__"];
4
- const SELF_ASKING_TOOLS = new Set(["mcp__sourceFiles__request_file", "mcp__sourceFiles__retire_source", "mcp__knowledge__knowledge_retire", "TodoWrite"]);
4
+ const SELF_ASKING_TOOLS = new Set(["TodoWrite"]);
5
5
  /**
6
6
  * PreToolUse hook for "interactive" mode: pauses before every action and asks for
7
7
  * confirmation (by keyboard or by file, see humanInput.ts), like reviewing a plan step
@@ -11,14 +11,14 @@ const SELF_ASKING_TOOLS = new Set(["mcp__sourceFiles__request_file", "mcp__sourc
11
11
  * session.ts's `ModeControl`): the hook stays registered and, while inactive, gives no
12
12
  * decision at all, so the tool call goes on exactly as if the hook weren't there.
13
13
  */
14
- export function createStepGate(runDir, isActive = () => true) {
14
+ export function createStepGate(runDir, isActive = () => true, selfAskingTools = []) {
15
15
  return async (input) => {
16
16
  if (!isActive())
17
17
  return {};
18
18
  const pre = input;
19
19
  // A tool that asks the person itself (an approval, a question, the plan, a file, retiring
20
20
  // a source) or only keeps the task list isn't asked about first: that would be asking to ask.
21
- if (SELF_ASKING_SERVERS.some((prefix) => pre.tool_name.startsWith(prefix)) || SELF_ASKING_TOOLS.has(pre.tool_name))
21
+ if (SELF_ASKING_SERVERS.some((prefix) => pre.tool_name.startsWith(prefix)) || SELF_ASKING_TOOLS.has(pre.tool_name) || selfAskingTools.includes(pre.tool_name))
22
22
  return {};
23
23
  const answer = await askForDecision(runDir, {
24
24
  title: t().proposedAction,
@@ -56,5 +56,5 @@ export function detectLanguage(option) {
56
56
  */
57
57
  export function replyLanguageInstruction(language) {
58
58
  const name = LANGUAGE_NAMES[language];
59
- return `Language: reply in ${name}, whatever language these instructions, your skills or the human's name are in. Switch to another language only when the human's message is clearly written in it; a greeting, a slash command or a single word doesn't count.`;
59
+ return `Language: use ${name} both to reply and for everything you keep (knowledge base pages, preferences, notes, the log), whatever language these instructions, your skills, your sources or the human's name are in. Switch both to another language only when the human's message is clearly written in it; a greeting, a slash command or a single word doesn't count.`;
60
60
  }
@@ -22,6 +22,17 @@ export const de = {
22
22
  resumeHint: "↑/↓ wählen · Enter fortsetzen · Esc abbrechen",
23
23
  resumeQuestion: "Nummer des Gesprächs, das fortgesetzt werden soll (Enter zum Abbrechen): ",
24
24
  noEarlierRuns: "(keine früheren Gespräche zum Fortsetzen)",
25
+ commandResume: "Ein früheres Gespräch wählen und fortsetzen",
26
+ commandPlan: "In den Planmodus und zurück",
27
+ commandExtensions: "Was läuft, was aus ist und warum; eine ein- oder ausschalten",
28
+ extensionsRunning: (names) => `Aktiv: ${names}`,
29
+ extensionsNone: "(keine Erweiterungen)",
30
+ extensionOff: (name, reason) => `${name} ist aus: ${reason}`,
31
+ extensionsInstalled: "Installiert:",
32
+ extensionToggled: (name, enabled) => `(${name} ${enabled ? "aktiviert" : "deaktiviert"}: die Sitzung wird mit derselben Unterhaltung neu geöffnet)`,
33
+ extensionNotInstalled: (name) => `(${name} ist nicht installiert: /extensions listet sie auf)`,
34
+ extensionsUsage: "(Verwendung: /extensions, /extensions enable <Name>, /extensions disable <Name>)",
35
+ extensionsCantReopen: "(dieser Chat kann seine Sitzung nicht neu öffnen: im Terminal aktivieren oder deaktivieren und neu starten)",
25
36
  currentRun: "(dieses)",
26
37
  resumed: (date) => `(Gespräch vom ${date} fortgesetzt)`,
27
38
  suggestionKey: "Tab",
@@ -72,7 +83,7 @@ export const de = {
72
83
  planCommentQuestion: "Was soll sich ändern? (Enter zum Überspringen): ",
73
84
  retireLines: (source, why, reason, replacedBy, folder) => [
74
85
  `Quelle: ${source}`,
75
- why === "wrong" ? "Warum: sie ist falsch; ihre Zusammenfassung wird zurückgezogen" : "Warum: sie wird ersetzt; ihre Zusammenfassung wird als ersetzt markiert",
86
+ why === "wrong" ? "Warum: sie ist falsch" : "Warum: eine bessere ersetzt sie",
76
87
  `Grund: ${reason}`,
77
88
  ...(replacedBy ? [`Ersetzt durch: ${replacedBy}`] : []),
78
89
  `Sie wird nach ${folder}.agent-kit/retired/ verschoben, nicht gelöscht.`,
@@ -91,22 +102,6 @@ export const de = {
91
102
  Task: ["hat {n} Subagenten gestartet", "hat {n} Subagenten gestartet"],
92
103
  mcp__time__current_time: ["Uhrzeit abgefragt", "Uhrzeit {n}-mal abgefragt"],
93
104
  mcp__time__date_math: ["Daten berechnet", "Daten {n}-mal berechnet"],
94
- mcp__sourceFiles__list_sources: ["Quellen geprüft", "Quellen {n}-mal geprüft"],
95
- mcp__sourceFiles__extract_text: ["{n} Dokument gelesen", "{n} Dokumente gelesen"],
96
- mcp__sourceFiles__save_to_sources: ["{n} Quelle gespeichert", "{n} Quellen gespeichert"],
97
- mcp__sourceFiles__download_to_sources: ["{n} Quelle heruntergeladen", "{n} Quellen heruntergeladen"],
98
- mcp__sourceFiles__request_file: ["um {n} Datei gebeten", "um {n} Dateien gebeten"],
99
- mcp__sourceFiles__retire_source: ["{n} Quelle zurückgezogen", "{n} Quellen zurückgezogen"],
100
- mcp__knowledge__knowledge_index: ["Index gelesen", "Index {n}-mal gelesen"],
101
- mcp__knowledge__knowledge_search: ["Wissensbasis durchsucht", "Wissensbasis {n}-mal durchsucht"],
102
- mcp__knowledge__knowledge_read: ["{n} Seite gelesen", "{n} Seiten gelesen"],
103
- mcp__knowledge__knowledge_create: ["{n} Seite angelegt", "{n} Seiten angelegt"],
104
- mcp__knowledge__knowledge_edit: ["{n} Seite bearbeitet", "{n} Seiten bearbeitet"],
105
- mcp__knowledge__knowledge_rewrite: ["{n} Seite neu geschrieben", "{n} Seiten neu geschrieben"],
106
- mcp__knowledge__knowledge_supersede: ["{n} Seite ersetzt", "{n} Seiten ersetzt"],
107
- mcp__knowledge__knowledge_retire: ["{n} Seite zurückgezogen", "{n} Seiten zurückgezogen"],
108
- mcp__knowledge__knowledge_log: ["Protokoll aktualisiert", "Protokoll {n}-mal aktualisiert"],
109
- mcp__knowledge__knowledge_check: ["Wissensbasis geprüft", "Wissensbasis {n}-mal geprüft"],
110
105
  mcp__approvals__ask_human: ["{n} Frage gestellt", "{n} Fragen gestellt"],
111
106
  mcp__approvals__present_plan: ["Plan vorgelegt", "Plan {n}-mal vorgelegt"],
112
107
  },
@@ -120,7 +115,6 @@ export const de = {
120
115
  labels: {
121
116
  askingApproval: (summary) => `Bittet um menschliche Genehmigung: ${summary}`,
122
117
  waitingManual: "Wartet auf einen manuellen Eingriff",
123
- savingToSources: (destination) => `Speichert ${destination} in den Quellen`,
124
118
  checkingTime: "Datum und Uhrzeit abfragen",
125
119
  calculatingDates: "Daten berechnen",
126
120
  reading: (file) => `Liest ${file}`,
@@ -139,21 +133,6 @@ export const de = {
139
133
  applyingSkill: (skill) => `Wendet den Skill "${skill}" an`,
140
134
  aSkill: "einen Skill",
141
135
  updatingTasks: "Aufgabenliste aktualisieren",
142
- listingSources: "Quellen prüfen",
143
- extractingText: (source) => `${source} lesen`,
144
- downloading: (url) => `${url} in die Quellen herunterladen`,
145
- requestingFile: (description) => `Um eine Datei bitten: ${description}`,
146
- retiringSource: (source) => `Quelle ${source} zurückziehen`,
147
- knowledgeIndex: "Index der Wissensbasis lesen",
148
- knowledgeSearch: (query) => `Wissensbasis nach "${query}" durchsuchen`,
149
- knowledgeRead: (page) => `${page} lesen`,
150
- knowledgeCreate: (page) => `${page} anlegen`,
151
- knowledgeEdit: (page) => `${page} bearbeiten`,
152
- knowledgeRewrite: (page) => `${page} neu schreiben`,
153
- knowledgeSupersede: (page) => `${page} als ersetzt markieren`,
154
- knowledgeRetire: (page) => `${page} zurückziehen`,
155
- knowledgeLog: "Protokoll der Wissensbasis aktualisieren",
156
- knowledgeCheck: "Wissensbasis prüfen",
157
136
  askingHuman: (question) => `Frage: ${question}`,
158
137
  presentingPlan: "Plan vorlegen",
159
138
  },