@falkenslab/agent-kit 0.18.0 → 0.20.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 (102) hide show
  1. package/assets/extension-launcher.mjs +24 -0
  2. package/dist/chat/chatController.d.ts +267 -0
  3. package/dist/chat/chatController.js +536 -0
  4. package/dist/chat/extensions.d.ts +18 -0
  5. package/dist/chat/extensions.js +44 -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 +11 -1
  9. package/dist/{tui → chat}/runs.js +1 -1
  10. package/dist/core/agentSpec.d.ts +28 -50
  11. package/dist/core/extensions.d.ts +128 -0
  12. package/dist/core/extensions.js +87 -0
  13. package/dist/core/externalExtensions.d.ts +143 -0
  14. package/dist/core/externalExtensions.js +368 -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/liveExtensions.d.ts +51 -0
  20. package/dist/core/liveExtensions.js +70 -0
  21. package/dist/core/marketplaces.d.ts +118 -0
  22. package/dist/core/marketplaces.js +242 -0
  23. package/dist/core/messages/de.js +12 -32
  24. package/dist/core/messages/en.d.ts +15 -16
  25. package/dist/core/messages/en.js +12 -32
  26. package/dist/core/messages/es.js +12 -32
  27. package/dist/core/messages/fr.js +12 -32
  28. package/dist/core/messages/index.d.ts +6 -0
  29. package/dist/core/messages/index.js +12 -1
  30. package/dist/core/packaged.d.ts +10 -0
  31. package/dist/core/packaged.js +38 -0
  32. package/dist/core/pluginAgents.d.ts +20 -0
  33. package/dist/core/pluginAgents.js +76 -0
  34. package/dist/core/pluginDownloads.d.ts +14 -0
  35. package/dist/core/pluginDownloads.js +141 -0
  36. package/dist/core/runner.js +21 -9
  37. package/dist/core/session.d.ts +38 -6
  38. package/dist/core/session.js +200 -99
  39. package/dist/core/sessionFacts.d.ts +85 -0
  40. package/dist/core/sessionFacts.js +49 -0
  41. package/dist/core/toolLabels.d.ts +24 -6
  42. package/dist/core/toolLabels.js +24 -39
  43. package/dist/extensions/awareness/index.d.ts +22 -0
  44. package/dist/extensions/awareness/index.js +42 -0
  45. package/dist/extensions/awareness/labels.d.ts +3 -0
  46. package/dist/extensions/awareness/labels.js +12 -0
  47. package/dist/extensions/awareness/tools.d.ts +5 -0
  48. package/dist/extensions/awareness/tools.js +54 -0
  49. package/dist/extensions/knowledge/index.d.ts +22 -0
  50. package/dist/extensions/knowledge/index.js +38 -0
  51. package/dist/{core → extensions/knowledge}/knowledgeStore.d.ts +2 -1
  52. package/dist/extensions/knowledge/labels.d.ts +3 -0
  53. package/dist/extensions/knowledge/labels.js +119 -0
  54. package/dist/{core/knowledge.d.ts → extensions/knowledge/prompt.d.ts} +4 -4
  55. package/dist/{core/knowledge.js → extensions/knowledge/prompt.js} +10 -7
  56. package/dist/{core/tools/knowledgeTools.d.ts → extensions/knowledge/tools.d.ts} +1 -1
  57. package/dist/{core/tools/knowledgeTools.js → extensions/knowledge/tools.js} +2 -2
  58. package/dist/extensions/memory/index.d.ts +21 -0
  59. package/dist/extensions/memory/index.js +59 -0
  60. package/dist/extensions/memory/labels.d.ts +3 -0
  61. package/dist/extensions/memory/labels.js +58 -0
  62. package/dist/extensions/memory/memoryStore.d.ts +53 -0
  63. package/dist/extensions/memory/memoryStore.js +88 -0
  64. package/dist/extensions/memory/tools.d.ts +9 -0
  65. package/dist/extensions/memory/tools.js +82 -0
  66. package/dist/extensions/sources/index.d.ts +21 -0
  67. package/dist/extensions/sources/index.js +42 -0
  68. package/dist/extensions/sources/labels.d.ts +3 -0
  69. package/dist/extensions/sources/labels.js +82 -0
  70. package/dist/{core/tools/saveToSources.d.ts → extensions/sources/tools.d.ts} +1 -1
  71. package/dist/{core/tools/saveToSources.js → extensions/sources/tools.js} +5 -5
  72. package/dist/index.d.ts +19 -9
  73. package/dist/index.js +16 -7
  74. package/dist/tui/chatTui.d.ts +11 -20
  75. package/dist/tui/chatTui.js +75 -230
  76. package/dist/tui/extensionCommand.d.ts +30 -0
  77. package/dist/tui/extensionCommand.js +257 -0
  78. package/dist/tui/ink/runChatInk.d.ts +4 -3
  79. package/dist/tui/ink/runChatInk.js +101 -217
  80. package/extensions/awareness/.claude-plugin/plugin.json +10 -0
  81. package/{assets/agent-help → extensions/awareness/skills/help}/SKILL.md +4 -3
  82. package/{assets/knowledge-plugin → extensions/knowledge}/.claude-plugin/plugin.json +7 -1
  83. package/extensions/memory/.claude-plugin/plugin.json +10 -0
  84. package/extensions/memory/commands/forget.md +8 -0
  85. package/extensions/memory/commands/list.md +5 -0
  86. package/extensions/sources/.claude-plugin/plugin.json +10 -0
  87. package/package.json +3 -2
  88. package/dist/core/agentHelp.d.ts +0 -30
  89. package/dist/core/agentHelp.js +0 -55
  90. /package/dist/{core → extensions/knowledge}/fileKnowledgeStore.d.ts +0 -0
  91. /package/dist/{core → extensions/knowledge}/fileKnowledgeStore.js +0 -0
  92. /package/dist/{core → extensions/knowledge}/knowledgeStore.js +0 -0
  93. /package/dist/{core → extensions/sources}/extractText.d.ts +0 -0
  94. /package/dist/{core → extensions/sources}/extractText.js +0 -0
  95. /package/dist/{core → extensions/sources}/sources.d.ts +0 -0
  96. /package/dist/{core → extensions/sources}/sources.js +0 -0
  97. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/ingest.md +0 -0
  98. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/lint.md +0 -0
  99. /package/{assets/knowledge-plugin → extensions/knowledge}/commands/query.md +0 -0
  100. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-ingest/SKILL.md +0 -0
  101. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-lint/SKILL.md +0 -0
  102. /package/{assets/knowledge-plugin → extensions/knowledge}/skills/knowledge-query/SKILL.md +0 -0
@@ -0,0 +1,368 @@
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
+ needs: Array.isArray(kit.needs) ? kit.needs.filter((program) => typeof program === "string") : [],
62
+ servers: await pluginServers(dir, raw),
63
+ readOnlyTools: kit.readOnlyTools ?? [],
64
+ labels: kit.labels ?? {},
65
+ ...(kit.help ? { help: kit.help } : {}),
66
+ };
67
+ }
68
+ /**
69
+ * A plugin's MCP servers, where Claude Code looks for them: `mcpServers` in `plugin.json` (inline,
70
+ * or a path to a JSON file), else `.mcp.json` at its root, with or without the `mcpServers` key.
71
+ */
72
+ async function pluginServers(dir, raw) {
73
+ const read = async (file) => {
74
+ try {
75
+ return JSON.parse(await readFile(path.join(dir, file), "utf8"));
76
+ }
77
+ catch (error) {
78
+ if (error.code === "ENOENT")
79
+ return undefined;
80
+ throw new Error(`${file} can't be read as JSON.`, { cause: error });
81
+ }
82
+ };
83
+ let declared = raw.mcpServers;
84
+ if (typeof declared === "string")
85
+ declared = await read(declared);
86
+ if (declared === undefined)
87
+ declared = await read(".mcp.json");
88
+ if (declared && typeof declared === "object" && "mcpServers" in declared)
89
+ declared = declared.mcpServers;
90
+ if (declared === undefined || declared === null)
91
+ return {};
92
+ if (typeof declared !== "object")
93
+ throw new Error("its MCP servers aren't a JSON object.");
94
+ const servers = {};
95
+ for (const [name, server] of Object.entries(declared)) {
96
+ if (!server || typeof server.command !== "string")
97
+ throw new Error(`its MCP server "${name}" has no command.`);
98
+ servers[name] = { command: server.command, ...(Array.isArray(server.args) ? { args: server.args.map(String) } : {}), ...(server.env ? { env: server.env } : {}) };
99
+ }
100
+ return servers;
101
+ }
102
+ /** `${CLAUDE_PLUGIN_ROOT}` (the extension's folder) and `${VAR}` (the agent's variable) in a server's values. */
103
+ function expand(value, root) {
104
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => (name === "CLAUDE_PLUGIN_ROOT" ? root : (process.env[name] ?? "")));
105
+ }
106
+ /**
107
+ * Why one of an extension's servers can't run through the launcher (only Node, its script
108
+ * first, inside the extension), or how the kit starts it.
109
+ */
110
+ function serverLaunch(name, server, root) {
111
+ if (!/^node(\.exe)?$/i.test(path.basename(server.command)))
112
+ return `runs its server "${name}" with "${server.command}": only Node servers can be installed`;
113
+ const [script, ...args] = (server.args ?? []).map((arg) => expand(arg, root));
114
+ if (!script)
115
+ return `gives its server "${name}" no script to run`;
116
+ const entry = path.resolve(root, script);
117
+ if (path.relative(root, entry).startsWith(".."))
118
+ return `runs its server "${name}" from outside its folder (${script})`;
119
+ return { entry, args, env: Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, expand(String(value), root)])) };
120
+ }
121
+ /** Why an extension's servers can't run, or nothing: each must be Node, with its script there. */
122
+ async function serversProblem(manifest, root) {
123
+ for (const [name, server] of Object.entries(manifest.servers)) {
124
+ const launch = serverLaunch(name, server, root);
125
+ if (typeof launch === "string")
126
+ return launch;
127
+ if (!(await stat(launch.entry).catch(() => null))?.isFile())
128
+ return `has no script at ${path.relative(root, launch.entry)} for its server "${name}"`;
129
+ }
130
+ return undefined;
131
+ }
132
+ /** SHA-256 over an extension's files (their paths and contents, in order), `.git` aside. */
133
+ export async function hashExtension(dir) {
134
+ const hash = createHash("sha256");
135
+ async function walk(relative) {
136
+ const entries = (await readdir(path.join(dir, relative), { withFileTypes: true })).filter((entry) => entry.name !== ".git");
137
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
138
+ const child = relative ? `${relative}/${entry.name}` : entry.name;
139
+ if (entry.isDirectory())
140
+ await walk(child);
141
+ else if (entry.isFile())
142
+ hash.update(`${child}\0`).update(await readFile(path.join(dir, child))).update("\0");
143
+ }
144
+ }
145
+ await walk("");
146
+ return hash.digest("hex");
147
+ }
148
+ /**
149
+ * Whether `version` (x.y.z) is in `range`: comparators separated by spaces, all of which must
150
+ * hold (`>=0.19.0 <0.21.0`, `=0.19.2`, `0.19.2`).
151
+ */
152
+ export function inKitRange(version, range) {
153
+ const parse = (text) => text.split("-")[0].split(".").map((part) => Number(part) || 0);
154
+ const compare = (a, b) => {
155
+ for (let i = 0; i < 3; i++)
156
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
157
+ return (a[i] ?? 0) - (b[i] ?? 0);
158
+ return 0;
159
+ };
160
+ const current = parse(version);
161
+ return range
162
+ .trim()
163
+ .split(/\s+/)
164
+ .every((comparator) => {
165
+ const match = /^(>=|<=|>|<|=)?\s*v?(\d+(?:\.\d+){0,2})$/.exec(comparator);
166
+ if (!match)
167
+ return false;
168
+ const order = compare(current, parse(match[2]));
169
+ switch (match[1]) {
170
+ case ">=":
171
+ return order >= 0;
172
+ case "<=":
173
+ return order <= 0;
174
+ case ">":
175
+ return order > 0;
176
+ case "<":
177
+ return order < 0;
178
+ default:
179
+ return order === 0;
180
+ }
181
+ });
182
+ }
183
+ /** Every extension installed in the scopes, the project's first. */
184
+ export async function listInstalled(dirs) {
185
+ const installed = [];
186
+ for (const scope of ["project", "agent"]) {
187
+ const scopeDir = dirs[scope];
188
+ if (!scopeDir)
189
+ continue;
190
+ for (const [name, lock] of Object.entries(await readLock(scopeDir))) {
191
+ const dir = path.join(scopeDir, name);
192
+ const manifest = await readExternalManifest(dir).catch(() => undefined);
193
+ installed.push({ name, scope, dir, lock, shadowed: installed.some((other) => other.name === name), ...(manifest ? { manifest } : {}) });
194
+ }
195
+ }
196
+ return installed;
197
+ }
198
+ /** The launcher that starts an external extension's server with a clean environment. */
199
+ export function extensionLauncherPath() {
200
+ // Node runs it as another process: outside the archive when packaged (#42).
201
+ return outsideArchive(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "assets", "extension-launcher.mjs"));
202
+ }
203
+ /** Its tools' chat labels in the kit's language, from its manifest: `{field}` takes the input; for each of its servers. */
204
+ function manifestToolLabels(manifest) {
205
+ const language = getLanguage();
206
+ const fill = (template, input) => template.replace(/\{(\w+)\}/g, (_, field) => (input[field] === undefined ? "" : truncate(String(input[field]), 60)));
207
+ return Object.fromEntries(Object.entries(manifest.labels).flatMap(([tool, byLanguage]) => {
208
+ const texts = byLanguage[language] ?? byLanguage.en;
209
+ if (!texts)
210
+ return [];
211
+ const label = { label: (input) => fill(texts.label, input), ...(texts.phrase ? { phrase: texts.phrase } : {}) };
212
+ return Object.keys(manifest.servers).map((server) => [`mcp__${server}__${tool}`, label]);
213
+ }));
214
+ }
215
+ /** The extension an installed one makes: its servers started through the launcher, its rules from its manifest. */
216
+ function externalExtension(installed, manifest) {
217
+ return {
218
+ name: manifest.name,
219
+ plugin: installed.dir,
220
+ external: true,
221
+ async contribute() {
222
+ const servers = {};
223
+ for (const [name, server] of Object.entries(manifest.servers)) {
224
+ const launch = serverLaunch(name, server, installed.dir);
225
+ if (typeof launch === "string")
226
+ continue; // checked when it loaded
227
+ // Its `env` reaches it (the SDK merges it with the agent's); the launcher removes the rest.
228
+ // Inside Electron, process.execPath is the app itself: ELECTRON_RUN_AS_NODE makes it run
229
+ // the launcher as Node (the launcher removes it, with the rest, once it runs).
230
+ const env = process.versions.electron ? { ...launch.env, ELECTRON_RUN_AS_NODE: "1" } : launch.env;
231
+ servers[name] = { type: "stdio", command: process.execPath, args: [extensionLauncherPath(), launch.entry, JSON.stringify(Object.keys(launch.env)), ...launch.args], env };
232
+ }
233
+ return {
234
+ mcpServers: servers,
235
+ readOnlyTools: Object.keys(manifest.servers).flatMap((server) => manifest.readOnlyTools.map((tool) => `mcp__${server}__${tool}`)),
236
+ toolLabels: manifestToolLabels(manifest),
237
+ ...(manifest.help ? { helpLines: [manifest.help] } : {}),
238
+ };
239
+ },
240
+ };
241
+ }
242
+ /** The installed extensions a session runs with, and those it can't (with why). Disabled ones are neither. */
243
+ export async function loadExternalExtensions(dirs) {
244
+ const extensions = [];
245
+ const off = [];
246
+ for (const installed of await listInstalled(dirs)) {
247
+ if (installed.shadowed || !installed.lock.enabled)
248
+ continue;
249
+ const reason = await problem(installed);
250
+ if (typeof reason === "string")
251
+ off.push({ name: installed.name, reason });
252
+ else
253
+ extensions.push(externalExtension(installed, reason));
254
+ }
255
+ return { extensions, off };
256
+ }
257
+ /** Why an installed extension can't load, or its manifest when it can. */
258
+ /** Whether a program is on the `PATH`, as a shell would find it (with `PATHEXT`'s extensions on Windows). */
259
+ export async function onPath(program, env = process.env) {
260
+ const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
261
+ const extensions = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").map((extension) => extension.toLowerCase())] : [""];
262
+ for (const dir of dirs) {
263
+ for (const extension of extensions) {
264
+ const found = await stat(path.join(dir, `${program}${extension}`)).then((entry) => entry.isFile(), () => false);
265
+ if (found)
266
+ return true;
267
+ }
268
+ }
269
+ return false;
270
+ }
271
+ async function problem(installed) {
272
+ let manifest;
273
+ try {
274
+ if ((await hashExtension(installed.dir)) !== installed.lock.sha256)
275
+ return "has files that changed since it was installed: install it again to trust them";
276
+ manifest = await readExternalManifest(installed.dir);
277
+ }
278
+ catch (error) {
279
+ return `can't be read (${error instanceof Error ? error.message : String(error)})`;
280
+ }
281
+ if (manifest.name !== installed.name)
282
+ return `is installed as "${installed.name}" but its manifest says "${manifest.name}"`;
283
+ if (manifest.kit && !inKitRange(agentKitVersion(), manifest.kit))
284
+ return `works with agent-kit ${manifest.kit}, and this agent runs ${agentKitVersion()}`;
285
+ const missing = [];
286
+ for (const program of manifest.needs)
287
+ if (!(await onPath(program)))
288
+ missing.push(program);
289
+ if (missing.length)
290
+ return `needs ${missing.join(", ")}, which ${missing.length === 1 ? "isn't" : "aren't"} on this computer (on the PATH)`;
291
+ return (await serversProblem(manifest, installed.dir)) ?? manifest;
292
+ }
293
+ const exec = promisify(execFile);
294
+ /** Whether `source` names a git repository rather than a folder. */
295
+ export function isGitSource(source) {
296
+ return /^(https?:\/\/|git@|ssh:\/\/|git:\/\/|file:\/\/|github:)/.test(source) || /\.git(#.*)?$/.test(source);
297
+ }
298
+ /**
299
+ * Installs an extension into a scope from a folder or a git repository (`url#ref`, `sha` to pin
300
+ * a commit, and `subdir` for one inside it): copies its files (never runs a script), checks its
301
+ * manifest, and locks it, enabled, with the marketplace it came from when it did (`origin`, for a
302
+ * temporary folder, is what the lock says it came from). An extension with the same name in that
303
+ * scope is replaced.
304
+ */
305
+ export async function addExtension(source, scopeDir, options = {}) {
306
+ let from = path.resolve(source);
307
+ let commit;
308
+ let clone;
309
+ if (isGitSource(source)) {
310
+ const [url, ref] = source.replace(/^github:/, "https://github.com/").split("#");
311
+ clone = await mkdtemp(path.join(os.tmpdir(), "agent-kit-extension-"));
312
+ // A pinned commit needs the history to check it out; otherwise the tip is enough.
313
+ await exec("git", ["clone", ...(options.sha ? [] : ["--depth", "1"]), ...(ref ? ["--branch", ref] : []), url, clone]);
314
+ if (options.sha)
315
+ await exec("git", ["-C", clone, "checkout", "--quiet", options.sha]);
316
+ commit = (await exec("git", ["-C", clone, "rev-parse", "HEAD"])).stdout.trim();
317
+ if (options.sha && commit !== options.sha.toLowerCase())
318
+ throw new Error(`${url} has no commit ${options.sha}.`);
319
+ from = clone;
320
+ }
321
+ try {
322
+ if (options.subdir)
323
+ from = path.join(from, options.subdir);
324
+ const manifest = await readExternalManifest(from);
325
+ const servers = await serversProblem(manifest, from);
326
+ if (servers)
327
+ throw new Error(`${manifest.name} can't be installed: it ${servers}.`);
328
+ const target = path.join(scopeDir, manifest.name);
329
+ const lock = await readLock(scopeDir);
330
+ const replaced = Boolean(lock[manifest.name]);
331
+ await rm(target, { recursive: true, force: true });
332
+ await mkdir(scopeDir, { recursive: true });
333
+ await cp(from, target, { recursive: true, filter: (file) => path.basename(file) !== ".git" });
334
+ lock[manifest.name] = {
335
+ source: options.origin ?? (isGitSource(source) ? `${source}${options.subdir ? ` (${options.subdir})` : ""}` : from),
336
+ ...(commit ? { commit } : {}),
337
+ ...(options.marketplace ? { marketplace: options.marketplace } : {}),
338
+ sha256: await hashExtension(target),
339
+ enabled: true,
340
+ installed: new Date().toISOString(),
341
+ };
342
+ await writeLock(scopeDir, lock);
343
+ return { name: manifest.name, replaced, ...(commit ? { commit } : {}) };
344
+ }
345
+ finally {
346
+ if (clone)
347
+ await rm(clone, { recursive: true, force: true });
348
+ }
349
+ }
350
+ /** Removes an extension from a scope (its files and its lock entry); false when it wasn't there. */
351
+ export async function removeExtension(scopeDir, name) {
352
+ const lock = await readLock(scopeDir);
353
+ if (!lock[name])
354
+ return false;
355
+ delete lock[name];
356
+ await rm(path.join(scopeDir, name), { recursive: true, force: true });
357
+ await writeLock(scopeDir, lock);
358
+ return true;
359
+ }
360
+ /** Enables or disables an installed extension in a scope; false when it isn't there. */
361
+ export async function setExtensionEnabled(scopeDir, name, enabled) {
362
+ const lock = await readLock(scopeDir);
363
+ if (!lock[name])
364
+ return false;
365
+ lock[name] = { ...lock[name], enabled };
366
+ await writeLock(scopeDir, lock);
367
+ return true;
368
+ }
@@ -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,
@@ -0,0 +1,51 @@
1
+ import type { ExtensionsStatus } from "./session.js";
2
+ import type { SessionLive, StaticSessionFacts } from "./sessionFacts.js";
3
+ import type { ToolLabels } from "./toolLabels.js";
4
+ /**
5
+ * Turning an installed extension off and on in a running session (#49), without reopening it.
6
+ * Only one that was running when the session opened: its MCP servers are toggled
7
+ * (`toggleMcpServer()`, which removes their tools from the model's context and brings them back,
8
+ * confirmed empirically) and its plugin, a copy in the run's folder, is emptied or filled again
9
+ * before `reloadPlugins()` (its skills, commands and subagents go and come back, confirmed
10
+ * empirically). The kit's own lists follow: the plan gate's read-only tools, the subagent gates'
11
+ * allowed types, the chat's labels, `/extensions` and what `about_me` says. One that wasn't
12
+ * running has no plugin path the session knows: it takes reopening the session.
13
+ *
14
+ * Not through `setMcpServers()`: it leaves the servers given at start alone (confirmed
15
+ * empirically: removing one from its set removes nothing).
16
+ */
17
+ /** What an installed extension brought to this session, to take it away and bring it back. */
18
+ export interface LiveExtension {
19
+ name: string;
20
+ /** Where it's installed (its scope's folder): the plugin copy is filled from there. */
21
+ installedDir: string;
22
+ scopeDir: string;
23
+ /** Its plugin, copied into the run's folder for this session. */
24
+ pluginCopy: string;
25
+ servers: string[];
26
+ readOnlyTools: string[];
27
+ toolLabels: ToolLabels;
28
+ /** Its subagents, as the type gate names them (`<plugin>:<name>`). */
29
+ agents: string[];
30
+ description: string;
31
+ provides: string[];
32
+ help: string[];
33
+ }
34
+ /** The session's own lists the switch keeps in step: the same objects the gates, the chat and the view read. */
35
+ export interface LiveTargets {
36
+ readOnlyTools: string[];
37
+ allowedSubagentTypes: string[];
38
+ toolLabels: ToolLabels;
39
+ status: ExtensionsStatus;
40
+ facts: StaticSessionFacts;
41
+ /** The running session's controls, once it has started. */
42
+ controls: () => SessionLive;
43
+ }
44
+ /**
45
+ * Turns `name` on or off in the running session; false when it can't be done here (it wasn't
46
+ * running when the session opened, the session hasn't started, or its files changed since it
47
+ * was installed), and the session must be opened again.
48
+ */
49
+ export type ExtensionSwitch = (name: string, enabled: boolean) => Promise<boolean>;
50
+ /** The switch for a session's installed extensions. */
51
+ export declare function createExtensionSwitch(extensions: readonly LiveExtension[], targets: LiveTargets): ExtensionSwitch;
@@ -0,0 +1,70 @@
1
+ import { cp, readdir, rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { hashExtension, listInstalled, readLock } from "./externalExtensions.js";
4
+ /** The switch for a session's installed extensions. */
5
+ export function createExtensionSwitch(extensions, targets) {
6
+ const off = new Set();
7
+ return async (name, enabled) => {
8
+ const extension = extensions.find((candidate) => candidate.name === name);
9
+ const controls = targets.controls();
10
+ if (!extension || !controls.toggleMcpServer || !controls.reloadPlugins)
11
+ return false;
12
+ if (enabled === !off.has(name))
13
+ return true; // already as asked
14
+ if (enabled) {
15
+ // Its files as they were installed, or nothing: a changed one is for a new session to judge.
16
+ const lock = (await readLock(extension.scopeDir))[name];
17
+ if (!lock || lock.sha256 !== (await hashExtension(extension.installedDir)))
18
+ return false;
19
+ await cp(extension.installedDir, extension.pluginCopy, { recursive: true });
20
+ }
21
+ else {
22
+ for (const entry of await readdir(extension.pluginCopy))
23
+ await rm(path.join(extension.pluginCopy, entry), { recursive: true, force: true });
24
+ }
25
+ await controls.reloadPlugins();
26
+ for (const server of extension.servers)
27
+ await controls.toggleMcpServer(server, enabled);
28
+ if (enabled)
29
+ off.delete(name);
30
+ else
31
+ off.add(name);
32
+ follow(extension, enabled, targets);
33
+ targets.status.installed = await listInstalled(targets.status.dirs);
34
+ return true;
35
+ };
36
+ }
37
+ /** The kit's lists, as the extension is now. */
38
+ function follow(extension, enabled, targets) {
39
+ const without = (list, items) => {
40
+ for (const item of items) {
41
+ const index = list.indexOf(item);
42
+ if (index >= 0)
43
+ list.splice(index, 1);
44
+ }
45
+ };
46
+ const { status, facts } = targets;
47
+ if (enabled) {
48
+ targets.readOnlyTools.push(...extension.readOnlyTools);
49
+ targets.allowedSubagentTypes.push(...extension.agents);
50
+ Object.assign(targets.toolLabels, extension.toolLabels);
51
+ status.active.push(extension.name);
52
+ status.about[extension.name] = { description: extension.description, provides: extension.provides };
53
+ status.inactive = status.inactive.filter((entry) => entry.name !== extension.name);
54
+ facts.extensions.active.push({ name: extension.name, description: extension.description, provides: extension.provides, servers: extension.servers, help: extension.help });
55
+ facts.extensions.inactive = facts.extensions.inactive.filter((entry) => entry.name !== extension.name);
56
+ }
57
+ else {
58
+ without(targets.readOnlyTools, extension.readOnlyTools);
59
+ without(targets.allowedSubagentTypes, extension.agents);
60
+ // The same object the chat reads its labels from: changed in place, readonly to everyone else.
61
+ for (const tool of Object.keys(extension.toolLabels))
62
+ delete targets.toolLabels[tool];
63
+ without(status.active, [extension.name]);
64
+ delete status.about[extension.name];
65
+ const reason = { name: extension.name, reason: "was turned off in this session" };
66
+ status.inactive.push(reason);
67
+ facts.extensions.active = facts.extensions.active.filter((entry) => entry.name !== extension.name);
68
+ facts.extensions.inactive.push(reason);
69
+ }
70
+ }