@falkenslab/agent-kit 0.19.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 (45) hide show
  1. package/dist/chat/chatController.d.ts +4 -1
  2. package/dist/chat/chatController.js +43 -17
  3. package/dist/chat/extensions.d.ts +7 -3
  4. package/dist/chat/extensions.js +11 -8
  5. package/dist/chat/runs.d.ts +3 -0
  6. package/dist/chat/runs.js +1 -1
  7. package/dist/core/agentSpec.d.ts +20 -56
  8. package/dist/core/extensions.d.ts +21 -12
  9. package/dist/core/extensions.js +8 -18
  10. package/dist/core/externalExtensions.d.ts +15 -3
  11. package/dist/core/externalExtensions.js +33 -5
  12. package/dist/core/liveExtensions.d.ts +51 -0
  13. package/dist/core/liveExtensions.js +70 -0
  14. package/dist/core/marketplaces.d.ts +118 -0
  15. package/dist/core/marketplaces.js +242 -0
  16. package/dist/core/messages/de.js +1 -0
  17. package/dist/core/messages/en.d.ts +2 -0
  18. package/dist/core/messages/en.js +1 -0
  19. package/dist/core/messages/es.js +1 -0
  20. package/dist/core/messages/fr.js +1 -0
  21. package/dist/core/pluginDownloads.d.ts +14 -0
  22. package/dist/core/pluginDownloads.js +141 -0
  23. package/dist/core/runner.js +6 -1
  24. package/dist/core/session.d.ts +11 -4
  25. package/dist/core/session.js +74 -22
  26. package/dist/core/sessionFacts.d.ts +5 -0
  27. package/dist/core/sessionFacts.js +1 -0
  28. package/dist/extensions/awareness/index.d.ts +12 -3
  29. package/dist/extensions/awareness/index.js +18 -15
  30. package/dist/extensions/awareness/tools.d.ts +1 -1
  31. package/dist/extensions/awareness/tools.js +3 -3
  32. package/dist/extensions/knowledge/index.d.ts +20 -6
  33. package/dist/extensions/knowledge/index.js +32 -29
  34. package/dist/extensions/knowledge/knowledgeStore.d.ts +1 -1
  35. package/dist/extensions/knowledge/prompt.d.ts +4 -4
  36. package/dist/extensions/knowledge/prompt.js +4 -4
  37. package/dist/extensions/memory/index.d.ts +15 -6
  38. package/dist/extensions/memory/index.js +34 -31
  39. package/dist/extensions/sources/index.d.ts +17 -5
  40. package/dist/extensions/sources/index.js +31 -29
  41. package/dist/index.d.ts +6 -1
  42. package/dist/index.js +5 -0
  43. package/dist/tui/extensionCommand.d.ts +11 -2
  44. package/dist/tui/extensionCommand.js +129 -5
  45. package/package.json +1 -1
@@ -0,0 +1,141 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { inKitRange } from "./externalExtensions.js";
6
+ async function fflate() {
7
+ try {
8
+ return (await import("fflate"));
9
+ }
10
+ catch (error) {
11
+ throw new Error('Installing from an npm or archive source needs the optional "fflate" library: npm install fflate', { cause: error });
12
+ }
13
+ }
14
+ async function download(url) {
15
+ if (!url.startsWith("https://"))
16
+ throw new Error(`Only https downloads: ${url}.`);
17
+ const response = await fetch(url);
18
+ if (!response.ok)
19
+ throw new Error(`${url} answered ${response.status}.`);
20
+ return new Uint8Array(await response.arrayBuffer());
21
+ }
22
+ /** Files into `dir`, each at its relative path; one that would land outside it is refused. */
23
+ async function writeFiles(dir, files) {
24
+ const root = path.resolve(dir);
25
+ for (const [name, data] of files) {
26
+ const target = path.resolve(root, name);
27
+ if (!target.startsWith(`${root}${path.sep}`) || name.split(/[\\/]/).includes(".."))
28
+ throw new Error(`The download has a file outside its folder: ${name}.`);
29
+ await mkdir(path.dirname(target), { recursive: true });
30
+ await writeFile(target, data);
31
+ }
32
+ }
33
+ /** The regular files of a tar archive, by path (ustar, with pax and GNU long names). */
34
+ export function untar(tar) {
35
+ const files = new Map();
36
+ const text = (from, length) => new TextDecoder().decode(tar.subarray(from, from + length)).replace(/\0.*$/s, "");
37
+ let offset = 0;
38
+ let longName;
39
+ while (offset + 512 <= tar.length) {
40
+ const name = text(offset, 100);
41
+ if (!name)
42
+ break; // the two empty blocks at the end
43
+ const size = parseInt(text(offset + 124, 12).trim() || "0", 8);
44
+ const type = text(offset + 156, 1);
45
+ const prefix = text(offset + 345, 155);
46
+ const body = tar.subarray(offset + 512, offset + 512 + size);
47
+ if (type === "x") {
48
+ longName = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(new TextDecoder().decode(body))?.[1] ?? longName;
49
+ }
50
+ else if (type === "L") {
51
+ longName = new TextDecoder().decode(body).replace(/\0.*$/s, "");
52
+ }
53
+ else {
54
+ if (type === "0" || type === "" || type === "7")
55
+ files.set(longName ?? (prefix ? `${prefix}/${name}` : name), body);
56
+ longName = undefined;
57
+ }
58
+ offset += 512 + Math.ceil(size / 512) * 512;
59
+ }
60
+ return files;
61
+ }
62
+ /** The highest of `versions` that `wanted` asks for: an exact version, or a range (`^`, `~`, comparators, `*`). */
63
+ export function pickVersion(versions, wanted) {
64
+ const range = wanted.trim();
65
+ if (versions.includes(range))
66
+ return range;
67
+ const comparators = range.split(/\s+/).flatMap((part) => {
68
+ if (part === "*" || part === "x" || part === "")
69
+ return [];
70
+ const caret = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(part);
71
+ if (caret) {
72
+ const [major, minor, patch] = caret.slice(1).map(Number);
73
+ const upper = major > 0 ? `${major + 1}.0.0` : minor > 0 ? `0.${minor + 1}.0` : `0.0.${patch + 1}`;
74
+ return [`>=${major}.${minor}.${patch}`, `<${upper}`];
75
+ }
76
+ const tilde = /^~(\d+)\.(\d+)\.(\d+)$/.exec(part);
77
+ if (tilde)
78
+ return [`>=${tilde[1]}.${tilde[2]}.${tilde[3]}`, `<${tilde[1]}.${Number(tilde[2]) + 1}.0`];
79
+ return [part];
80
+ });
81
+ const stable = versions.filter((version) => /^\d+\.\d+\.\d+$/.test(version));
82
+ const matching = stable.filter((version) => !comparators.length || inKitRange(version, comparators.join(" ")));
83
+ const order = (version) => version.split(".").map(Number);
84
+ return matching.sort((a, b) => {
85
+ const [x, y] = [order(a), order(b)];
86
+ for (let i = 0; i < 3; i++)
87
+ if (x[i] !== y[i])
88
+ return x[i] - y[i];
89
+ return 0;
90
+ }).at(-1);
91
+ }
92
+ /** An npm package into a new temporary folder: `{ dir, version }`. */
93
+ export async function fetchNpmPlugin(name, wanted = "latest", registry = "https://registry.npmjs.org") {
94
+ if (!/^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i.test(name))
95
+ throw new Error(`"${name}" isn't an npm package name.`);
96
+ const response = await fetch(`${registry.replace(/\/+$/, "")}/${name.replace("/", "%2f")}`, { headers: { accept: "application/json" } });
97
+ if (!response.ok)
98
+ throw new Error(`The registry has no package ${name} (${response.status}).`);
99
+ const metadata = (await response.json());
100
+ const version = metadata["dist-tags"]?.[wanted] ?? pickVersion(Object.keys(metadata.versions ?? {}), wanted);
101
+ const dist = version ? metadata.versions?.[version]?.dist : undefined;
102
+ if (!version || !dist?.tarball)
103
+ throw new Error(`${name} has no version ${wanted}.`);
104
+ const tarball = await download(dist.tarball);
105
+ const sri = /^sha512-(.+)$/.exec(dist.integrity ?? "");
106
+ if (!sri)
107
+ throw new Error(`${name}@${version} has no sha512 integrity to check it against.`);
108
+ if (createHash("sha512").update(tarball).digest("base64") !== sri[1])
109
+ throw new Error(`${name}@${version}'s download doesn't match its integrity.`);
110
+ const { gunzipSync } = await fflate();
111
+ const dir = await mkdtemp(path.join(os.tmpdir(), "agent-kit-npm-"));
112
+ try {
113
+ // npm puts a package's files under package/.
114
+ const files = [...untar(gunzipSync(tarball))].flatMap(([file, data]) => (file.startsWith("package/") ? [[file.slice("package/".length), data]] : []));
115
+ await writeFiles(dir, files);
116
+ return { dir, version };
117
+ }
118
+ catch (error) {
119
+ await rm(dir, { recursive: true, force: true });
120
+ throw error;
121
+ }
122
+ }
123
+ /** A zip archive into a new temporary folder; the plugin is its root, or its only folder. */
124
+ export async function fetchArchivePlugin(url, sha256) {
125
+ const zip = await download(url);
126
+ if (sha256 && createHash("sha256").update(zip).digest("hex") !== sha256.toLowerCase())
127
+ throw new Error(`${url} doesn't match its sha256.`);
128
+ const { unzipSync } = await fflate();
129
+ const files = Object.entries(unzipSync(zip)).filter(([name]) => !name.endsWith("/"));
130
+ const dir = await mkdtemp(path.join(os.tmpdir(), "agent-kit-archive-"));
131
+ try {
132
+ await writeFiles(dir, files);
133
+ const tops = new Set(files.map(([name]) => name.split("/")[0]));
134
+ const only = tops.size === 1 && files.every(([name]) => name.includes("/")) ? [...tops][0] : "";
135
+ return { dir, root: path.join(dir, only) };
136
+ }
137
+ catch (error) {
138
+ await rm(dir, { recursive: true, force: true });
139
+ throw error;
140
+ }
141
+ }
@@ -31,7 +31,12 @@ export function runQuery(prompt, options) {
31
31
  // What the session is, for an extension that asks (sessionFacts.ts): the controls now, the
32
32
  // tools and skills when the session starts.
33
33
  const view = sessionViewOf(options);
34
- view?.attach({ supportedCommands: () => result.supportedCommands(), contextUsage: () => contextUsage() });
34
+ view?.attach({
35
+ supportedCommands: () => result.supportedCommands(),
36
+ contextUsage: () => contextUsage(),
37
+ toggleMcpServer: (server, enabled) => result.toggleMcpServer(server, enabled),
38
+ reloadPlugins: () => result.reloadPlugins(),
39
+ });
35
40
  // The main agent's tool calls by id, to name the results that come back for them.
36
41
  const toolNames = new Map();
37
42
  async function* generateEvents() {
@@ -1,12 +1,12 @@
1
1
  import type { Options, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { type TranscriptLogger } from "./hooks/transcriptLogger.js";
3
3
  import { type ModeControl } from "./modeControl.js";
4
- import type { KnowledgeStore } from "../extensions/knowledge/knowledgeStore.js";
5
4
  import { type ExtensionDirs, type InstalledExtension } from "./externalExtensions.js";
6
5
  import type { ToolLabels } from "./toolLabels.js";
7
6
  import type { AgentSpec, BaseSessionConfig } from "./agentSpec.js";
8
7
  import { type Language } from "./language.js";
9
8
  import { type RunFolder } from "./runs.js";
9
+ import { type ExtensionSwitch } from "./liveExtensions.js";
10
10
  /**
11
11
  * Builds the `options` object passed to the Agent SDK's `query()` — everything about
12
12
  * *how* the agent runs a session (system prompt, tools, MCP servers, hooks), independent
@@ -40,10 +40,10 @@ export declare function buildSessionOptions<TConfig extends BaseSessionConfig>(c
40
40
  toolLabels: ToolLabels;
41
41
  /** The installed extensions and what this session runs with, for `/extensions` (#37). */
42
42
  extensions: ExtensionsStatus;
43
- /** The knowledge base's store, when the knowledge extension is on (for a host that reads the knowledge base). */
44
- knowledgeStore?: KnowledgeStore;
45
- /** What each active extension hands the host (its contribution's `api`), by its name: the sources' `addSource`, the knowledge base's store. */
43
+ /** What each active extension hands the host (its contribution's `api`), by its name: the sources' `addSource`, the knowledge base's `knowledgeStore`. */
46
44
  apis: Record<string, Record<string, unknown>>;
45
+ /** Turns an installed extension off or on in the running session; false when that takes opening it again (#49). */
46
+ switchExtension: ExtensionSwitch;
47
47
  }>;
48
48
  export { createModeControl, togglePlanMode, type ModeControl } from "./modeControl.js";
49
49
  /** The extensions of a session: where they're installed, what's installed, what runs and what can't (with why). */
@@ -68,6 +68,13 @@ export interface ExtensionsStatus {
68
68
  * and digits as "-" (confirmed empirically).
69
69
  */
70
70
  export declare function claudeProjectDir(projectDir: string): string;
71
+ /**
72
+ * The SDK's `permissions.deny` rules for paths no file tool may read or change, each one and
73
+ * whatever is inside it. An absolute path is written `//` and POSIX: `C:\Users\x` is
74
+ * `//c/Users/x` (confirmed empirically on Windows; on POSIX `/home/x` is `//home/x`). `Read`
75
+ * rules reach the tools that read, `Edit` rules those that write.
76
+ */
77
+ export declare function denyRules(paths: readonly string[]): string[];
71
78
  /**
72
79
  * The skills of the plugins in `roots`, as the SDK names them: `<plugin>:<folder>`, the
73
80
  * plugin's name from its `.claude-plugin/plugin.json` and the skill's folder under `skills/`,
@@ -1,4 +1,4 @@
1
- import { readdir, readFile } from "node:fs/promises";
1
+ import { cp, readdir, readFile, rm } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { createTranscriptLogger } from "./hooks/transcriptLogger.js";
@@ -22,6 +22,7 @@ import { replyLanguageInstruction } from "./language.js";
22
22
  import { chooseLanguage } from "./messages/index.js";
23
23
  import { createRunStore } from "./runs.js";
24
24
  import { createSessionView, registerSessionView } from "./sessionFacts.js";
25
+ import { createExtensionSwitch } from "./liveExtensions.js";
25
26
  import { agentKitVersion } from "./version.js";
26
27
  /**
27
28
  * Builds the `options` object passed to the Agent SDK's `query()` — everything about
@@ -69,32 +70,39 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
69
70
  runDir,
70
71
  mode,
71
72
  interactive: mode !== "autonomous",
73
+ // What the active ones provide, once they're resolved below.
74
+ capabilities: new Set(),
72
75
  session: () => (late.view ? late.view.facts() : Promise.reject(new Error("The session isn't built yet: call session() from a tool, not from contribute()."))),
73
76
  };
74
77
  // The installed ones (#37), the project's over the agent's: those that can't load are off with
75
78
  // why, and so is one named like an extension the spec enables.
76
79
  const extensionDirs = config.extensionDirs ?? {};
77
80
  const external = await loadExternalExtensions(extensionDirs);
78
- const enabledNames = new Set((spec.extensions ?? []).map((entry) => (typeof entry === "string" ? entry : entry.name)));
81
+ const enabledNames = new Set((spec.extensions ?? []).map((extension) => extension.name));
79
82
  const resolved = resolveExtensions([...(spec.extensions ?? []), ...external.extensions.filter((extension) => !enabledNames.has(extension.name))], extensionContext);
80
83
  resolved.inactive.push(...external.off, ...external.extensions.filter((extension) => enabledNames.has(extension.name)).map((extension) => ({ name: extension.name, reason: "has the name of one this agent already has" })));
84
+ // Each installed one's plugin, copied into the run's folder: the running session empties and
85
+ // fills the copy to turn the extension off and on (liveExtensions.ts), never what's installed.
86
+ const installedDirs = new Map();
87
+ for (const { extension } of resolved.active.filter(({ extension }) => extension.external)) {
88
+ const copy = path.join(runDir, "extensions", extension.name);
89
+ await rm(copy, { recursive: true, force: true });
90
+ await cp(extension.plugin, copy, { recursive: true });
91
+ installedDirs.set(extension.name, extension.plugin);
92
+ extension.plugin = copy;
93
+ }
81
94
  const externalPlugins = new Set(resolved.active.filter(({ extension }) => extension.external).map(({ extension }) => path.resolve(extension.plugin)));
95
+ extensionContext.capabilities = resolved.capabilities;
82
96
  const added = await Promise.all(resolved.active.map(({ extension }) => extension.contribute(extensionContext)));
83
97
  const extensionPlugins = resolved.active.map(({ extension }) => extension.plugin);
84
98
  const fromExtensions = (pick) => added.flatMap((contribution) => pick(contribution) ?? []);
85
- const knowledgeStore = added.map((contribution) => contribution.api?.knowledgeStore).find(Boolean);
86
- // An extension may claim `knowledgeDir` as reached only through its tools (the knowledge base,
87
- // ADR-024); otherwise it's a folder of the agent's own notes, kept with the file tools under
88
- // its own rules.
89
- const claimed = (dir) => fromExtensions((contribution) => contribution.toolOnlyDirs).some((only) => path.resolve(only.dir) === path.resolve(dir));
90
- const notesDir = config.knowledgeDir && !claimed(config.knowledgeDir) ? config.knowledgeDir : undefined;
91
99
  const extraDirs = config.extraWritableDirs ?? [];
92
100
  const readableExtras = config.extraReadableDirs ?? [];
93
- // The file tools the session needs: all five for the notes and the extra writable folders,
94
- // the reading ones for the extra readable folders, and whatever the extensions ask for (the
95
- // sources' reading ones); without any of them, none.
101
+ // The file tools the session needs: all five for the extra writable folders, the reading ones
102
+ // for the extra readable folders, and whatever the extensions ask for (the sources' reading
103
+ // ones); without any of them, none.
96
104
  const wantedFileTools = new Set([
97
- ...(notesDir || extraDirs.length ? FILE_TOOLS : []),
105
+ ...(extraDirs.length ? FILE_TOOLS : []),
98
106
  ...(readableExtras.length ? ["Read", "Glob", "Grep"] : []),
99
107
  ...fromExtensions((contribution) => contribution.fileTools),
100
108
  ]);
@@ -105,10 +113,10 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
105
113
  // searchable but deliberately NOT writable: the only way to add to them is its tools.
106
114
  const readOnlyDirs = fromExtensions((contribution) => contribution.readOnlyDirs);
107
115
  const toolOnlyDirs = fromExtensions((contribution) => contribution.toolOnlyDirs);
108
- const writableDirs = [notesDir, ...extraDirs].filter((d) => Boolean(d));
109
- const searchableDirs = [notesDir, ...readOnlyDirs, ...extraDirs, ...readableExtras].filter((d) => Boolean(d));
116
+ const writableDirs = extraDirs;
117
+ const searchableDirs = [...readOnlyDirs, ...extraDirs, ...readableExtras];
110
118
  // Skills/commands/plugins are a separate concern from the file tools: an agent that
111
- // wants a plugin-provided skill/command but has no notes or sources folder shouldn't have
119
+ // wants a plugin-provided skill/command but has no folder of its own shouldn't have
112
120
  // to invent one just to get `cwd`/`skills: "all"`/`plugins` wired up. Gated on either
113
121
  // signal, not on `pluginRoots` alone, so an agent with file tools keeps getting the SDK's
114
122
  // own project-level `.claude/skills`/`.claude/commands` discovery.
@@ -160,6 +168,10 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
160
168
  // map, and inherits the full session tools (Bash included) rather than the narrow list
161
169
  // each declared subagent actually declares.
162
170
  const allowedSubagentTypes = [...new Set([...(subagents?.allowedSubagentTypes ?? []), ...Object.keys(fromPlugins)])];
171
+ // The extensions' read-only tools, for the plan gate: a list the live switch changes (liveExtensions.ts).
172
+ const readOnlyTools = fromExtensions((contribution) => contribution.readOnlyTools);
173
+ // What no file tool may touch: the agent's denied paths and the SDK's own credentials.
174
+ const deniedPaths = [...(config.deniedPaths ?? []), path.join(claudeConfigDir(), ".credentials.json")];
163
175
  const transcriptPath = path.join(runDir, "transcript.jsonl");
164
176
  const transcriptLogger = createTranscriptLogger(transcriptPath, config.secrets ?? []);
165
177
  const promptSections = [
@@ -195,6 +207,9 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
195
207
  // An installed extension's plugin may carry hooks: none run (ADR-025). The kit's own,
196
208
  // passed in `hooks`, still do (confirmed empirically).
197
209
  ...(externalPlugins.size ? { disableAllHooks: true } : {}),
210
+ // The denied paths again, as the SDK's own rules: a second layer under the file scope gate,
211
+ // which a tool the gate doesn't know of can't pass either (#49).
212
+ permissions: { deny: denyRules(deniedPaths) },
198
213
  },
199
214
  // No built-in tools except, if applicable, Read/Write/Edit/Glob/Grep scoped to the
200
215
  // project's own folders (fileScopeGate below), and WebFetch/WebSearch
@@ -230,7 +245,7 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
230
245
  // skills, on top of the plugin-provided built-ins from spec.pluginRoots()
231
246
  // below. skipMcpDiscovery: true on those plugins is the caller's choice.
232
247
  cwd: config.projectDir,
233
- // Empty when there's no knowledgeDir/sourcesDir (the plugin-only case) — an
248
+ // Empty when there's no folder of the agent's or its extensions' (the plugin-only case) — an
234
249
  // empty array is a valid, no-op value for this SDK option, not omitted, since
235
250
  // this whole block is already conditioned on includeSkillsAndPlugins.
236
251
  additionalDirectories: searchableDirs,
@@ -264,7 +279,7 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
264
279
  searchableDirs,
265
280
  readOnlyDirs,
266
281
  // The SDK's own credentials, whatever the agent denies.
267
- deniedPaths: [...(config.deniedPaths ?? []), path.join(claudeConfigDir(), ".credentials.json")],
282
+ deniedPaths,
268
283
  // Read and Glob only in the agent's folders, plus what only the kit knows it
269
284
  // needs: the run folder, the plugins, the project's .claude/ (when loaded) and
270
285
  // the SDK's large tool results. Never the rest of the disk (~/.ssh, other
@@ -287,7 +302,7 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
287
302
  hooks: [
288
303
  createPlanGate({
289
304
  projectDir: config.projectDir,
290
- readOnlyTools: fromExtensions((contribution) => contribution.readOnlyTools),
305
+ readOnlyTools,
291
306
  ...(spec.planMode?.isPlanFile ? { isPlanFile: (filePath) => spec.planMode.isPlanFile(filePath, config) } : {}),
292
307
  ...(spec.planMode?.isReadOnlyTool ? { isReadOnlyTool: spec.planMode.isReadOnlyTool } : {}),
293
308
  }, () => modeControl.mode === "plan"),
@@ -321,7 +336,7 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
321
336
  inactive: resolved.inactive,
322
337
  };
323
338
  // What the session is, for the extensions that ask (sessionFacts.ts, #43).
324
- late.view = createSessionView({
339
+ const viewBase = {
325
340
  ...(spec.identity ? { identity: spec.identity } : {}),
326
341
  kitVersion: agentKitVersion(),
327
342
  language,
@@ -333,15 +348,39 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
333
348
  servers: Object.keys(added[index]?.mcpServers ?? {}),
334
349
  help: added[index]?.helpLines ?? [],
335
350
  })),
336
- inactive: resolved.inactive,
351
+ // Its own list: /extensions' status has another, and the live switch changes both.
352
+ inactive: [...resolved.inactive],
337
353
  },
338
354
  subagents: Object.entries(allAgents).map(([name, agent]) => ({ name, description: agent.description })),
339
355
  skills: sdkOptions.skills === "all" ? await pluginSkills(pluginRoots) : [...(sdkOptions.skills ?? [])],
340
356
  runDir,
341
- }, modeControl);
357
+ };
358
+ late.view = createSessionView(viewBase, modeControl);
342
359
  registerSessionView(sdkOptions, late.view);
360
+ // Turning an installed extension off and on while the session runs (#49).
361
+ const switchExtension = createExtensionSwitch(resolved.active.flatMap(({ extension, manifest }, index) => {
362
+ const installedDir = installedDirs.get(extension.name);
363
+ if (!installedDir)
364
+ return [];
365
+ const contribution = added[index];
366
+ return [
367
+ {
368
+ name: extension.name,
369
+ installedDir,
370
+ scopeDir: path.dirname(installedDir),
371
+ pluginCopy: extension.plugin,
372
+ servers: Object.keys(contribution?.mcpServers ?? {}),
373
+ readOnlyTools: contribution?.readOnlyTools ?? [],
374
+ toolLabels: contribution?.toolLabels ?? {},
375
+ agents: Object.keys(fromPlugins).filter((agent) => agent.startsWith(`${extension.name}:`)),
376
+ description: manifest.description,
377
+ provides: manifest.provides,
378
+ help: contribution?.helpLines ?? [],
379
+ },
380
+ ];
381
+ }), { readOnlyTools, allowedSubagentTypes, toolLabels, status: extensions, facts: viewBase, controls: () => late.view?.controls() ?? {} });
343
382
  const apis = Object.fromEntries(resolved.active.flatMap(({ extension }, index) => (added[index]?.api ? [[extension.name, added[index].api]] : [])));
344
- return { options: sdkOptions, transcriptLogger, transcriptPath, modeControl, language, toolLabels, extensions, apis, ...(knowledgeStore ? { knowledgeStore } : {}) };
383
+ return { options: sdkOptions, transcriptLogger, transcriptPath, modeControl, language, toolLabels, extensions, apis, switchExtension };
345
384
  }
346
385
  export { createModeControl, togglePlanMode } from "./modeControl.js";
347
386
  /** The Claude Code configuration folder the SDK's CLI uses: `CLAUDE_CONFIG_DIR`, or `~/.claude`. */
@@ -356,6 +395,19 @@ function claudeConfigDir() {
356
395
  export function claudeProjectDir(projectDir) {
357
396
  return path.join(claudeConfigDir(), "projects", path.resolve(projectDir).replace(/[^a-zA-Z0-9]/g, "-"));
358
397
  }
398
+ /**
399
+ * The SDK's `permissions.deny` rules for paths no file tool may read or change, each one and
400
+ * whatever is inside it. An absolute path is written `//` and POSIX: `C:\Users\x` is
401
+ * `//c/Users/x` (confirmed empirically on Windows; on POSIX `/home/x` is `//home/x`). `Read`
402
+ * rules reach the tools that read, `Edit` rules those that write.
403
+ */
404
+ export function denyRules(paths) {
405
+ return paths.flatMap((denied) => {
406
+ const posix = path.resolve(denied).replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`);
407
+ const rule = `/${posix}`;
408
+ return [`Read(${rule})`, `Read(${rule}/**)`, `Edit(${rule})`, `Edit(${rule}/**)`];
409
+ });
410
+ }
359
411
  /** The built-in file tools, in the order the session lists them. */
360
412
  const FILE_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep"];
361
413
  /**
@@ -58,6 +58,9 @@ export interface SessionLive {
58
58
  skills?: string[];
59
59
  supportedCommands?: () => Promise<SlashCommand[]>;
60
60
  contextUsage?: () => Promise<ContextUsage | null>;
61
+ /** The running session's controls an installed extension is turned off and on with (liveExtensions.ts). */
62
+ toggleMcpServer?: (server: string, enabled: boolean) => Promise<void>;
63
+ reloadPlugins?: () => Promise<unknown>;
61
64
  }
62
65
  /** The facts known when the options are built: everything but the mode and what only the running session knows. */
63
66
  export type StaticSessionFacts = Omit<SessionFacts, "mode" | "switchableModes" | "tools" | "commands" | "context" | "extensions"> & {
@@ -71,6 +74,8 @@ export type StaticSessionFacts = Omit<SessionFacts, "mode" | "switchableModes" |
71
74
  export interface SessionView {
72
75
  facts(): Promise<SessionFacts>;
73
76
  attach(live: SessionLive): void;
77
+ /** What the running session has attached so far. */
78
+ controls(): SessionLive;
74
79
  }
75
80
  /** The view of a session: its static facts, the mode read from `modeControl` on every call, and what the running session attaches. */
76
81
  export declare function createSessionView(base: StaticSessionFacts, modeControl: ModeControl): SessionView;
@@ -8,6 +8,7 @@ export function createSessionView(base, modeControl) {
8
8
  attach(more) {
9
9
  live = { ...live, ...more };
10
10
  },
11
+ controls: () => live,
11
12
  async facts() {
12
13
  const tools = live.tools ?? [];
13
14
  const [commands, context] = await Promise.all([
@@ -4,10 +4,19 @@ import type { Extension } from "../../core/extensions.js";
4
4
  export declare function awarenessPluginRoot(): string;
5
5
  /** The "Who you are" section of the system prompt: name, version, what it is, the kit's version, and where to look. */
6
6
  export declare function identityPromptSection(identity: AgentIdentity): string;
7
+ /** The awareness extension's options. */
8
+ export interface AwarenessOptions {
9
+ /**
10
+ * Absolute path of a markdown guide to the agent's own domain (its commands, configuration,
11
+ * folders), which `about_me` gives the agent when the person asks.
12
+ */
13
+ guide?: string;
14
+ }
7
15
  /**
8
16
  * The awareness (#43): the agent knows who it is and what it is at each moment of a session,
9
17
  * and the person can ask it. A "Who you are" section, `about_me` (the session's facts, read
10
- * anew on every call through `ExtensionContext.session()`, and the agent's `helpGuide`), and
11
- * the `help` skill, how the kit's chat is used, which doesn't change.
18
+ * anew on every call through `ExtensionContext.session()`, and the agent's `guide`), and
19
+ * the `help` skill, how the kit's chat is used, which doesn't change. It needs the spec's
20
+ * `identity`.
12
21
  */
13
- export declare const awarenessExtension: Extension;
22
+ export declare function awareness(options?: AwarenessOptions): Extension;
@@ -21,19 +21,22 @@ You are ${who}. You are built on agent-kit ${agentKitVersion()}, which provides
21
21
  /**
22
22
  * The awareness (#43): the agent knows who it is and what it is at each moment of a session,
23
23
  * and the person can ask it. A "Who you are" section, `about_me` (the session's facts, read
24
- * anew on every call through `ExtensionContext.session()`, and the agent's `helpGuide`), and
25
- * the `help` skill, how the kit's chat is used, which doesn't change.
24
+ * anew on every call through `ExtensionContext.session()`, and the agent's `guide`), and
25
+ * the `help` skill, how the kit's chat is used, which doesn't change. It needs the spec's
26
+ * `identity`.
26
27
  */
27
- export const awarenessExtension = {
28
- name: "awareness",
29
- plugin: awarenessPluginRoot(),
30
- missing: ({ spec }) => (spec.identity ? undefined : "needs `identity` in the spec"),
31
- async contribute({ spec, session }) {
32
- return {
33
- mcpServers: { awareness: createAwarenessServer(session, spec.helpGuide) },
34
- promptSection: identityPromptSection(spec.identity),
35
- readOnlyTools: ["mcp__awareness__about_me"],
36
- toolLabels: awarenessToolLabels(),
37
- };
38
- },
39
- };
28
+ export function awareness(options = {}) {
29
+ return {
30
+ name: "awareness",
31
+ plugin: awarenessPluginRoot(),
32
+ missing: ({ spec }) => (spec.identity ? undefined : "needs `identity` in the spec"),
33
+ async contribute({ spec, session }) {
34
+ return {
35
+ mcpServers: { awareness: createAwarenessServer(session, options.guide) },
36
+ promptSection: identityPromptSection(spec.identity),
37
+ readOnlyTools: ["mcp__awareness__about_me"],
38
+ toolLabels: awarenessToolLabels(),
39
+ };
40
+ },
41
+ };
42
+ }
@@ -2,4 +2,4 @@ import type { SessionFacts } from "../../core/sessionFacts.js";
2
2
  /** The session's facts as the model reads them: markdown, in English (ADR-019). */
3
3
  export declare function describeSession(facts: SessionFacts): string;
4
4
  /** The awareness's MCP server: `about_me`, from the session's facts and the agent's guide. */
5
- export declare function createAwarenessServer(session: () => Promise<SessionFacts>, helpGuide?: string): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
5
+ export declare function createAwarenessServer(session: () => Promise<SessionFacts>, guide?: string): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
@@ -41,12 +41,12 @@ export function describeSession(facts) {
41
41
  return sections.join("\n\n");
42
42
  }
43
43
  /** The awareness's MCP server: `about_me`, from the session's facts and the agent's guide. */
44
- export function createAwarenessServer(session, helpGuide) {
44
+ export function createAwarenessServer(session, guide) {
45
45
  const aboutMe = tool("about_me", "What you are right now: your mode and the ones the person can switch to, your extensions with their tools and the ones off (with why), your subagents, the commands the person can type, how full your context is. With `part: \"guide\"`, your own guide to your domain (your commands, configuration, folders). It changes during a session: call it whenever the person asks about you, rather than answering from memory.", { part: z.enum(["now", "guide"]).optional().describe("`now` (the default): what you are right now. `guide`: your own guide to your domain.") }, async ({ part }) => {
46
46
  if (part === "guide") {
47
- if (!helpGuide)
47
+ if (!guide)
48
48
  return ok("You have no guide of your own: for your domain, say only what your instructions tell you, and that the rest isn't documented.");
49
- return ok(await readFile(helpGuide, "utf8"));
49
+ return ok(await readFile(guide, "utf8"));
50
50
  }
51
51
  return ok(describeSession(await session()));
52
52
  }, { annotations: { readOnlyHint: true } });
@@ -1,8 +1,22 @@
1
- import type { Extension } from "../../core/extensions.js";
1
+ import type { BaseSessionConfig } from "../../core/agentSpec.js";
2
+ import { type Extension, type FolderOption } from "../../core/extensions.js";
3
+ import type { KnowledgeStore, PageType } from "./knowledgeStore.js";
4
+ /** The knowledge base's options. */
5
+ export interface KnowledgeOptions<TConfig extends BaseSessionConfig = BaseSessionConfig> {
6
+ /** The knowledge base's folder, reached only through its tools, never the file tools. Without a folder the extension is off. */
7
+ dir: FolderOption<TConfig>;
8
+ /**
9
+ * The agent's own page types, besides the kit's (summary, concept, entity, synthesis,
10
+ * preference): each with its folder (`""` for the root), index section, description (told to
11
+ * the model) and template.
12
+ */
13
+ pageTypes?: PageType[];
14
+ /** Its store, instead of the kit's over markdown files in `dir` (e.g. a database or a vector store implementing `KnowledgeStore`). */
15
+ store?(config: TConfig, dir: string): KnowledgeStore;
16
+ }
2
17
  /**
3
- * The built-in knowledge base (`config.knowledgeDir`, ADR-008, ADR-024): an interlinked wiki the
4
- * agent keeps only through its `knowledge_*` tools, over a `KnowledgeStore` (the kit's over
5
- * markdown files, or the agent's own). Not enabled, `knowledgeDir` is left to the core as a
6
- * folder of the agent's own notes.
18
+ * The built-in knowledge base (ADR-008, ADR-024): an interlinked wiki the agent keeps only through
19
+ * its `knowledge_*` tools, over a `KnowledgeStore` (the kit's over markdown files, or the agent's
20
+ * own). The host reads it through `apis.knowledge.knowledgeStore`.
7
21
  */
8
- export declare const knowledgeExtension: Extension;
22
+ export declare function knowledge<TConfig extends BaseSessionConfig = BaseSessionConfig>(options: KnowledgeOptions<TConfig>): Extension;
@@ -1,35 +1,38 @@
1
+ import { folderOf } from "../../core/extensions.js";
1
2
  import { createFileKnowledgeStore } from "./fileKnowledgeStore.js";
2
3
  import { knowledgeToolLabels } from "./labels.js";
3
4
  import { knowledgePluginRoot, knowledgePromptSection } from "./prompt.js";
4
5
  import { createKnowledgeServer } from "./tools.js";
5
6
  /**
6
- * The built-in knowledge base (`config.knowledgeDir`, ADR-008, ADR-024): an interlinked wiki the
7
- * agent keeps only through its `knowledge_*` tools, over a `KnowledgeStore` (the kit's over
8
- * markdown files, or the agent's own). Not enabled, `knowledgeDir` is left to the core as a
9
- * folder of the agent's own notes.
7
+ * The built-in knowledge base (ADR-008, ADR-024): an interlinked wiki the agent keeps only through
8
+ * its `knowledge_*` tools, over a `KnowledgeStore` (the kit's over markdown files, or the agent's
9
+ * own). The host reads it through `apis.knowledge.knowledgeStore`.
10
10
  */
11
- export const knowledgeExtension = {
12
- name: "knowledge",
13
- plugin: knowledgePluginRoot(),
14
- missing: ({ config }) => (config.knowledgeDir ? undefined : "needs `knowledgeDir` in the config"),
15
- async contribute({ config, spec, runDir, interactive }) {
16
- const knowledgeDir = config.knowledgeDir;
17
- const store = spec.knowledgeStore?.(config) ?? createFileKnowledgeStore(knowledgeDir, { pageTypes: spec.knowledgePageTypes });
18
- // The person's preferences, by title, for its prompt section (#33).
19
- const preferences = (await store.list()).filter((page) => page.type === "preference" && page.status === "active").map(({ id, title }) => ({ id, title }));
20
- return {
21
- // knowledge_retire asks a person, so it exists only where there is one.
22
- mcpServers: { knowledge: createKnowledgeServer(store, { runDir, interactive }) },
23
- promptSection: knowledgePromptSection({ withSources: Boolean(config.sourcesDir), pageTypes: store.types(), preferences }),
24
- // Never the file tools on the knowledge folder (ADR-024): the denial points to the tools.
25
- toolOnlyDirs: [{ dir: knowledgeDir, instead: "the knowledge_* tools" }],
26
- readOnlyTools: ["mcp__knowledge__knowledge_index", "mcp__knowledge__knowledge_search", "mcp__knowledge__knowledge_read", "mcp__knowledge__knowledge_check"],
27
- selfAskingTools: ["mcp__knowledge__knowledge_retire"],
28
- toolLabels: knowledgeToolLabels(),
29
- helpLines: [
30
- "You keep a knowledge base (your memory across sessions). Its commands: `/knowledge:ingest` (learn the new or changed files of the sources folder), `/knowledge:query <question>` (answer from it), `/knowledge:lint` (check it and fix what's mechanical).",
31
- ],
32
- api: { knowledgeStore: store },
33
- };
34
- },
35
- };
11
+ export function knowledge(options) {
12
+ return {
13
+ name: "knowledge",
14
+ plugin: knowledgePluginRoot(),
15
+ missing: ({ config }) => (folderOf(options.dir, config) ? undefined : "needs a folder (`dir`)"),
16
+ async contribute({ config, runDir, interactive, capabilities }) {
17
+ const knowledgeDir = folderOf(options.dir, config);
18
+ const store = options.store?.(config, knowledgeDir) ?? createFileKnowledgeStore(knowledgeDir, { pageTypes: options.pageTypes });
19
+ // The person's preferences, by title, for its prompt section (#33).
20
+ const preferences = (await store.list()).filter((page) => page.type === "preference" && page.status === "active").map(({ id, title }) => ({ id, title }));
21
+ return {
22
+ // knowledge_retire asks a person, so it exists only where there is one.
23
+ mcpServers: { knowledge: createKnowledgeServer(store, { runDir, interactive }) },
24
+ // Beside the sources, its summaries say which original they're about.
25
+ promptSection: knowledgePromptSection({ withSources: capabilities.has("sources"), pageTypes: store.types(), preferences }),
26
+ // Never the file tools on the knowledge folder (ADR-024): the denial points to the tools.
27
+ toolOnlyDirs: [{ dir: knowledgeDir, instead: "the knowledge_* tools" }],
28
+ readOnlyTools: ["mcp__knowledge__knowledge_index", "mcp__knowledge__knowledge_search", "mcp__knowledge__knowledge_read", "mcp__knowledge__knowledge_check"],
29
+ selfAskingTools: ["mcp__knowledge__knowledge_retire"],
30
+ toolLabels: knowledgeToolLabels(),
31
+ helpLines: [
32
+ "You keep a knowledge base (your memory across sessions). Its commands: `/knowledge:ingest` (learn the new or changed files of the sources folder), `/knowledge:query <question>` (answer from it), `/knowledge:lint` (check it and fix what's mechanical).",
33
+ ],
34
+ api: { knowledgeStore: store },
35
+ };
36
+ },
37
+ };
38
+ }
@@ -5,7 +5,7 @@
5
5
  * skills. A page is identified by its type and slug, `concept/bowline`, and links between
6
6
  * pages use that id too: `[Bowline](concept/bowline)`.
7
7
  */
8
- /** A kind of page: one of the kit's, or one an agent declares (`AgentSpec.knowledgePageTypes`). */
8
+ /** A kind of page: one of the kit's, or one an agent declares (`knowledge({ pageTypes })`). */
9
9
  export interface PageType {
10
10
  /** Its name, lowercase: `concept`, `topic`. Page ids start with it. */
11
11
  type: string;