@falkenslab/agent-kit 0.18.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 (96) 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 +30 -16
  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/planGate.d.ts +2 -0
  16. package/dist/core/hooks/planGate.js +1 -10
  17. package/dist/core/hooks/stepGate.d.ts +1 -1
  18. package/dist/core/hooks/stepGate.js +3 -3
  19. package/dist/core/messages/de.js +11 -32
  20. package/dist/core/messages/en.d.ts +13 -16
  21. package/dist/core/messages/en.js +11 -32
  22. package/dist/core/messages/es.js +11 -32
  23. package/dist/core/messages/fr.js +11 -32
  24. package/dist/core/messages/index.d.ts +6 -0
  25. package/dist/core/messages/index.js +12 -1
  26. package/dist/core/packaged.d.ts +10 -0
  27. package/dist/core/packaged.js +38 -0
  28. package/dist/core/pluginAgents.d.ts +20 -0
  29. package/dist/core/pluginAgents.js +76 -0
  30. package/dist/core/runner.js +16 -9
  31. package/dist/core/session.d.ts +30 -5
  32. package/dist/core/session.js +143 -94
  33. package/dist/core/sessionFacts.d.ts +80 -0
  34. package/dist/core/sessionFacts.js +48 -0
  35. package/dist/core/toolLabels.d.ts +24 -6
  36. package/dist/core/toolLabels.js +24 -39
  37. package/dist/extensions/awareness/index.d.ts +13 -0
  38. package/dist/extensions/awareness/index.js +39 -0
  39. package/dist/extensions/awareness/labels.d.ts +3 -0
  40. package/dist/extensions/awareness/labels.js +12 -0
  41. package/dist/extensions/awareness/tools.d.ts +5 -0
  42. package/dist/extensions/awareness/tools.js +54 -0
  43. package/dist/extensions/knowledge/index.d.ts +8 -0
  44. package/dist/extensions/knowledge/index.js +35 -0
  45. package/dist/{core → extensions/knowledge}/knowledgeStore.d.ts +1 -0
  46. package/dist/extensions/knowledge/labels.d.ts +3 -0
  47. package/dist/extensions/knowledge/labels.js +119 -0
  48. package/dist/{core/knowledge.d.ts → extensions/knowledge/prompt.d.ts} +1 -1
  49. package/dist/{core/knowledge.js → extensions/knowledge/prompt.js} +7 -4
  50. package/dist/{core/tools/knowledgeTools.d.ts → extensions/knowledge/tools.d.ts} +1 -1
  51. package/dist/{core/tools/knowledgeTools.js → extensions/knowledge/tools.js} +2 -2
  52. package/dist/extensions/memory/index.d.ts +12 -0
  53. package/dist/extensions/memory/index.js +56 -0
  54. package/dist/extensions/memory/labels.d.ts +3 -0
  55. package/dist/extensions/memory/labels.js +58 -0
  56. package/dist/extensions/memory/memoryStore.d.ts +53 -0
  57. package/dist/extensions/memory/memoryStore.js +88 -0
  58. package/dist/extensions/memory/tools.d.ts +9 -0
  59. package/dist/extensions/memory/tools.js +82 -0
  60. package/dist/extensions/sources/index.d.ts +9 -0
  61. package/dist/extensions/sources/index.js +40 -0
  62. package/dist/extensions/sources/labels.d.ts +3 -0
  63. package/dist/extensions/sources/labels.js +82 -0
  64. package/dist/{core/tools/saveToSources.d.ts → extensions/sources/tools.d.ts} +1 -1
  65. package/dist/{core/tools/saveToSources.js → extensions/sources/tools.js} +5 -5
  66. package/dist/index.d.ts +14 -9
  67. package/dist/index.js +11 -7
  68. package/dist/tui/chatTui.d.ts +11 -20
  69. package/dist/tui/chatTui.js +75 -230
  70. package/dist/tui/extensionCommand.d.ts +21 -0
  71. package/dist/tui/extensionCommand.js +133 -0
  72. package/dist/tui/ink/runChatInk.d.ts +4 -3
  73. package/dist/tui/ink/runChatInk.js +101 -217
  74. package/extensions/awareness/.claude-plugin/plugin.json +10 -0
  75. package/{assets/agent-help → extensions/awareness/skills/help}/SKILL.md +4 -3
  76. package/{assets/knowledge-plugin → extensions/knowledge}/.claude-plugin/plugin.json +7 -1
  77. package/extensions/memory/.claude-plugin/plugin.json +10 -0
  78. package/extensions/memory/commands/forget.md +8 -0
  79. package/extensions/memory/commands/list.md +5 -0
  80. package/extensions/sources/.claude-plugin/plugin.json +10 -0
  81. package/package.json +3 -2
  82. package/dist/core/agentHelp.d.ts +0 -30
  83. package/dist/core/agentHelp.js +0 -55
  84. /package/dist/{core → extensions/knowledge}/fileKnowledgeStore.d.ts +0 -0
  85. /package/dist/{core → extensions/knowledge}/fileKnowledgeStore.js +0 -0
  86. /package/dist/{core → extensions/knowledge}/knowledgeStore.js +0 -0
  87. /package/dist/{core → extensions/sources}/extractText.d.ts +0 -0
  88. /package/dist/{core → extensions/sources}/extractText.js +0 -0
  89. /package/dist/{core → extensions/sources}/sources.d.ts +0 -0
  90. /package/dist/{core → extensions/sources}/sources.js +0 -0
  91. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/ingest.md +0 -0
  92. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/lint.md +0 -0
  93. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/query.md +0 -0
  94. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-ingest/SKILL.md +0 -0
  95. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-lint/SKILL.md +0 -0
  96. /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
+ }
@@ -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,
@@ -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",
@@ -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
  },
@@ -29,6 +29,19 @@ export interface Messages {
29
29
  resumeHint: string;
30
30
  resumeQuestion: string;
31
31
  noEarlierRuns: string;
32
+ /** What the chat's own commands do, for a view that lists them. */
33
+ commandResume: string;
34
+ commandPlan: string;
35
+ commandExtensions: string;
36
+ /** /extensions (#37): what the session runs with, what's off and why, and enabling or disabling one. */
37
+ extensionsRunning(names: string): string;
38
+ extensionsNone: string;
39
+ extensionOff(name: string, reason: string): string;
40
+ extensionsInstalled: string;
41
+ extensionToggled(name: string, enabled: boolean): string;
42
+ extensionNotInstalled(name: string): string;
43
+ extensionsUsage: string;
44
+ extensionsCantReopen: string;
32
45
  currentRun: string;
33
46
  resumed(date: string): string;
34
47
  suggestionKey: string;
@@ -82,7 +95,6 @@ export interface Messages {
82
95
  labels: {
83
96
  askingApproval(summary: string): string;
84
97
  waitingManual: string;
85
- savingToSources(destination: string): string;
86
98
  checkingTime: string;
87
99
  calculatingDates: string;
88
100
  reading(file: string): string;
@@ -101,21 +113,6 @@ export interface Messages {
101
113
  applyingSkill(skill: string): string;
102
114
  aSkill: string;
103
115
  updatingTasks: string;
104
- listingSources: string;
105
- extractingText(source: string): string;
106
- downloading(url: string): string;
107
- requestingFile(description: string): string;
108
- retiringSource(source: string): string;
109
- knowledgeIndex: string;
110
- knowledgeSearch(query: string): string;
111
- knowledgeRead(page: string): string;
112
- knowledgeCreate(page: string): string;
113
- knowledgeEdit(page: string): string;
114
- knowledgeRewrite(page: string): string;
115
- knowledgeSupersede(page: string): string;
116
- knowledgeRetire(page: string): string;
117
- knowledgeLog: string;
118
- knowledgeCheck: string;
119
116
  askingHuman(question: string): string;
120
117
  presentingPlan: string;
121
118
  };
@@ -21,6 +21,17 @@ export const en = {
21
21
  resumeHint: "↑/↓ choose · Enter resume · Esc cancel",
22
22
  resumeQuestion: "Number of the conversation to resume (Enter to cancel): ",
23
23
  noEarlierRuns: "(no earlier conversations to resume)",
24
+ commandResume: "Pick an earlier conversation to go on with",
25
+ commandPlan: "Into plan mode, or back",
26
+ commandExtensions: "What runs, what's off and why; turn one on or off",
27
+ extensionsRunning: (names) => `Running with: ${names}`,
28
+ extensionsNone: "(no extensions)",
29
+ extensionOff: (name, reason) => `${name} is off: it ${reason}`,
30
+ extensionsInstalled: "Installed:",
31
+ extensionToggled: (name, enabled) => `(${name} ${enabled ? "enabled" : "disabled"}: the session reopens, with the same conversation)`,
32
+ extensionNotInstalled: (name) => `(${name} isn't installed: /extensions lists them)`,
33
+ extensionsUsage: "(usage: /extensions, /extensions enable <name>, /extensions disable <name>)",
34
+ extensionsCantReopen: "(this chat can't reopen its session: enable or disable it from the terminal, and start again)",
24
35
  currentRun: "(this one)",
25
36
  resumed: (date) => `(resumed the conversation of ${date})`,
26
37
  suggestionKey: "tab",
@@ -90,22 +101,6 @@ export const en = {
90
101
  Task: ["ran {n} subagent", "ran {n} subagents"],
91
102
  mcp__time__current_time: ["checked the time", "checked the time {n} times"],
92
103
  mcp__time__date_math: ["calculated dates", "calculated dates {n} times"],
93
- mcp__sourceFiles__list_sources: ["listed the sources", "listed the sources {n} times"],
94
- mcp__sourceFiles__extract_text: ["read {n} document", "read {n} documents"],
95
- mcp__sourceFiles__save_to_sources: ["saved {n} source", "saved {n} sources"],
96
- mcp__sourceFiles__download_to_sources: ["downloaded {n} source", "downloaded {n} sources"],
97
- mcp__sourceFiles__request_file: ["asked for {n} file", "asked for {n} files"],
98
- mcp__sourceFiles__retire_source: ["retired {n} source", "retired {n} sources"],
99
- mcp__knowledge__knowledge_index: ["read the index", "read the index {n} times"],
100
- mcp__knowledge__knowledge_search: ["searched the knowledge base", "searched the knowledge base {n} times"],
101
- mcp__knowledge__knowledge_read: ["read {n} page", "read {n} pages"],
102
- mcp__knowledge__knowledge_create: ["created {n} page", "created {n} pages"],
103
- mcp__knowledge__knowledge_edit: ["edited {n} page", "edited {n} pages"],
104
- mcp__knowledge__knowledge_rewrite: ["rewrote {n} page", "rewrote {n} pages"],
105
- mcp__knowledge__knowledge_supersede: ["superseded {n} page", "superseded {n} pages"],
106
- mcp__knowledge__knowledge_retire: ["retired {n} page", "retired {n} pages"],
107
- mcp__knowledge__knowledge_log: ["updated the log", "updated the log {n} times"],
108
- mcp__knowledge__knowledge_check: ["checked the knowledge base", "checked the knowledge base {n} times"],
109
104
  mcp__approvals__ask_human: ["asked {n} question", "asked {n} questions"],
110
105
  mcp__approvals__present_plan: ["presented the plan", "presented the plan {n} times"],
111
106
  },
@@ -119,7 +114,6 @@ export const en = {
119
114
  labels: {
120
115
  askingApproval: (summary) => `Asking for human approval: ${summary}`,
121
116
  waitingManual: "Waiting for a human to intervene manually",
122
- savingToSources: (destination) => `Saving ${destination} to the sources`,
123
117
  checkingTime: "Checking the date and time",
124
118
  calculatingDates: "Calculating dates",
125
119
  reading: (file) => `Reading ${file}`,
@@ -138,21 +132,6 @@ export const en = {
138
132
  applyingSkill: (skill) => `Applying the "${skill}" skill`,
139
133
  aSkill: "a skill",
140
134
  updatingTasks: "Updating the task list",
141
- listingSources: "Listing the sources",
142
- extractingText: (source) => `Reading ${source}`,
143
- downloading: (url) => `Downloading ${url} to the sources`,
144
- requestingFile: (description) => `Asking for a file: ${description}`,
145
- retiringSource: (source) => `Retiring the source ${source}`,
146
- knowledgeIndex: "Reading the knowledge base's index",
147
- knowledgeSearch: (query) => `Searching the knowledge base for "${query}"`,
148
- knowledgeRead: (page) => `Reading ${page}`,
149
- knowledgeCreate: (page) => `Creating ${page}`,
150
- knowledgeEdit: (page) => `Editing ${page}`,
151
- knowledgeRewrite: (page) => `Rewriting ${page}`,
152
- knowledgeSupersede: (page) => `Marking ${page} superseded`,
153
- knowledgeRetire: (page) => `Retiring ${page}`,
154
- knowledgeLog: "Updating the knowledge base's log",
155
- knowledgeCheck: "Checking the knowledge base",
156
135
  askingHuman: (question) => `Asking: ${question}`,
157
136
  presentingPlan: "Presenting the plan",
158
137
  },