@falkenslab/agent-kit 0.21.0 → 0.23.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 (57) hide show
  1. package/README.md +1 -4
  2. package/assets/extension-launcher.mjs +28 -5
  3. package/dist/chat/chatController.d.ts +60 -0
  4. package/dist/chat/chatController.js +212 -2
  5. package/dist/chat/extensions.d.ts +36 -2
  6. package/dist/chat/extensions.js +87 -3
  7. package/dist/chat/runs.d.ts +3 -1
  8. package/dist/chat/runs.js +1 -1
  9. package/dist/core/agentSpec.d.ts +11 -0
  10. package/dist/core/elicitation.d.ts +54 -0
  11. package/dist/core/elicitation.js +120 -0
  12. package/dist/core/extensionCatalog.d.ts +103 -0
  13. package/dist/core/extensionCatalog.js +235 -0
  14. package/dist/core/extensionOptions.d.ts +56 -0
  15. package/dist/core/extensionOptions.js +139 -0
  16. package/dist/core/extensionRequirements.d.ts +66 -0
  17. package/dist/core/extensionRequirements.js +112 -0
  18. package/dist/core/extensions.d.ts +75 -2
  19. package/dist/core/extensions.js +78 -6
  20. package/dist/core/externalExtensions.d.ts +101 -11
  21. package/dist/core/externalExtensions.js +263 -51
  22. package/dist/core/hooks/planGate.d.ts +2 -0
  23. package/dist/core/hooks/planGate.js +3 -0
  24. package/dist/core/interaction.d.ts +7 -0
  25. package/dist/core/liveExtensions.d.ts +15 -0
  26. package/dist/core/liveExtensions.js +33 -2
  27. package/dist/core/marketplaces.d.ts +7 -2
  28. package/dist/core/marketplaces.js +6 -4
  29. package/dist/core/messages/de.js +37 -1
  30. package/dist/core/messages/en.d.ts +44 -0
  31. package/dist/core/messages/en.js +37 -1
  32. package/dist/core/messages/es.js +37 -1
  33. package/dist/core/messages/fr.js +37 -1
  34. package/dist/core/npxPackages.d.ts +48 -0
  35. package/dist/core/npxPackages.js +121 -0
  36. package/dist/core/pluginDownloads.d.ts +2 -1
  37. package/dist/core/pluginDownloads.js +2 -2
  38. package/dist/core/readOnlyHints.d.ts +13 -0
  39. package/dist/core/readOnlyHints.js +26 -0
  40. package/dist/core/runner.js +2 -0
  41. package/dist/core/session.d.ts +17 -5
  42. package/dist/core/session.js +44 -11
  43. package/dist/core/sessionFacts.d.ts +11 -1
  44. package/dist/core/workspaceTrust.d.ts +12 -0
  45. package/dist/core/workspaceTrust.js +60 -0
  46. package/dist/extensions/awareness/tools.js +6 -2
  47. package/dist/index.d.ts +9 -2
  48. package/dist/index.js +7 -2
  49. package/dist/tui/extensionCommand.d.ts +10 -2
  50. package/dist/tui/extensionCommand.js +199 -34
  51. package/extensions/awareness/.claude-plugin/plugin.json +13 -5
  52. package/extensions/knowledge/.claude-plugin/plugin.json +13 -5
  53. package/extensions/memory/.claude-plugin/plugin.json +13 -5
  54. package/extensions/sources/.claude-plugin/plugin.json +13 -5
  55. package/package.json +7 -5
  56. package/schemas/agent-kit-metadata.schema.json +120 -0
  57. package/schemas/marketplace-catalog.schema.json +74 -0
@@ -5,10 +5,15 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { promisify } from "node:util";
8
+ import { capabilitiesProblem, parseCapabilities, providedBy } from "./extensions.js";
8
9
  import { getLanguage } from "./messages/index.js";
9
10
  import { truncate } from "./toolLabels.js";
10
11
  import { agentKitVersion } from "./version.js";
11
12
  import { outsideArchive } from "./packaged.js";
13
+ import { extensionSecretsPath, missingOptions, optionsNamed, parseOptionValue, parseUserConfig, readOptionValues, removeSecrets, storeOptionValues } from "./extensionOptions.js";
14
+ import { isTrusted } from "./workspaceTrust.js";
15
+ import { checkRequirements, describeMissing, findOnPath, parseRequirements, requirementsProblem } from "./extensionRequirements.js";
16
+ import { cachedPackageDir, cachedPackageProblem, cachePackage, npxServer, packageCacheDir, packageSpec } from "./npxPackages.js";
12
17
  export const LOCK_FILE = "extensions.lock.json";
13
18
  /**
14
19
  * An installed extension's data folder: `${CLAUDE_PLUGIN_DATA}` in its servers' arguments and
@@ -19,6 +24,39 @@ export const LOCK_FILE = "extensions.lock.json";
19
24
  export function extensionDataDir(scopeDir, name) {
20
25
  return path.join(scopeDir, ".data", name);
21
26
  }
27
+ /**
28
+ * Where a scope keeps what's the person's, not the workspace's: its extensions' data and secrets
29
+ * (#57). The agent's scope, its own folder; a project's, a folder of its own in the agent's
30
+ * (`<agent scope>/.projects/<project>-<hash>/`), so nothing private travels with a project. An
31
+ * agent without a scope of its own keeps it in the project's.
32
+ */
33
+ export function scopePrivateDir(dirs, scope) {
34
+ const scopeDir = dirs[scope];
35
+ if (!scopeDir)
36
+ return undefined;
37
+ if (scope === "agent" || !dirs.agent)
38
+ return scopeDir;
39
+ const resolved = path.resolve(scopeDir);
40
+ const key = createHash("sha256").update(resolved.toLowerCase()).digest("hex").slice(0, 12);
41
+ return path.join(dirs.agent, ".projects", `${path.basename(path.dirname(resolved)) || "project"}-${key}`);
42
+ }
43
+ /** The agent's store of extensions' code, by content (#57): where a project's scope keeps its extensions' code. */
44
+ export function codeStoreDir(dirs) {
45
+ return dirs.agent ? path.join(dirs.agent, ".store") : undefined;
46
+ }
47
+ /** Where a stored extension's code is: by its name and its files' hash. */
48
+ export function storedCodeDir(store, name, sha256) {
49
+ return path.join(store, name, sha256.slice(0, 16));
50
+ }
51
+ /**
52
+ * How an install into `scope` is done: its npx packages into the agent's cache, and a project's
53
+ * code into the agent's store when the agent has a scope of its own.
54
+ */
55
+ export function installTarget(dirs, scope) {
56
+ const packages = packageCacheDir(dirs);
57
+ const store = scope === "project" ? codeStoreDir(dirs) : undefined;
58
+ return { ...(packages ? { packages } : {}), ...(store ? { store } : {}) };
59
+ }
22
60
  /** A scope's lock: its extensions by name. */
23
61
  export async function readLock(scopeDir) {
24
62
  try {
@@ -48,11 +86,14 @@ export async function readExternalManifest(dir) {
48
86
  const name = raw.name;
49
87
  if (typeof name !== "string" || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name))
50
88
  throw new Error(`${file}: "name" must be kebab-case.`);
51
- const kit = (raw["agent-kit"] ?? {});
89
+ // The kit's data, where Claude Code keeps a plugin's own (`metadata`, which it doesn't read): a
90
+ // top-level key would fail `claude plugin validate --strict` (#55).
91
+ const kit = (raw.metadata?.["agent-kit"] ?? {});
52
92
  const text = (key) => (typeof raw[key] === "string" ? { [key]: raw[key] } : {});
53
93
  const author = raw.author;
54
94
  // `repository` may be a URL or `{ url }`, as in package.json.
55
95
  const repository = typeof raw.repository === "string" ? raw.repository : raw.repository?.url;
96
+ const capabilities = parseCapabilities(kit.capabilities);
56
97
  return {
57
98
  name,
58
99
  description: typeof raw.description === "string" ? raw.description : "",
@@ -64,16 +105,27 @@ export async function readExternalManifest(dir) {
64
105
  ...(typeof repository === "string" ? { repository } : {}),
65
106
  ...text("license"),
66
107
  ...(Array.isArray(raw.keywords) ? { keywords: raw.keywords.filter((keyword) => typeof keyword === "string") } : {}),
67
- provides: kit.provides ?? [],
108
+ provides: providedBy(kit.provides, capabilities),
68
109
  requires: kit.requires ?? [],
110
+ capabilities,
69
111
  ...(kit.kit ? { kit: kit.kit } : {}),
70
- needs: Array.isArray(kit.needs) ? kit.needs.filter((program) => typeof program === "string") : [],
112
+ requirements: parseRequirements(kit.requirements),
71
113
  servers: await pluginServers(dir, raw),
72
- readOnlyTools: kit.readOnlyTools ?? [],
114
+ ...(Array.isArray(kit.readOnlyTools) ? { readOnlyTools: kit.readOnlyTools.filter((tool) => typeof tool === "string") } : {}),
73
115
  labels: kit.labels ?? {},
74
116
  ...(kit.help ? { help: kit.help } : {}),
117
+ userConfig: parseUserConfig(raw.userConfig),
118
+ ...catalogLook(kit.catalog),
75
119
  };
76
120
  }
121
+ /** A manifest's `catalog` (#59): only its `logo` and `banner`, when they're paths. */
122
+ function catalogLook(raw) {
123
+ if (!raw || typeof raw !== "object")
124
+ return {};
125
+ const { logo, banner } = raw;
126
+ const look = { ...(typeof logo === "string" ? { logo } : {}), ...(typeof banner === "string" ? { banner } : {}) };
127
+ return Object.keys(look).length ? { catalog: look } : {};
128
+ }
77
129
  /**
78
130
  * A plugin's MCP servers, where Claude Code looks for them: `mcpServers` in `plugin.json` (inline,
79
131
  * or a path to a JSON file), else `.mcp.json` at its root, with or without the `mcpServers` key.
@@ -113,26 +165,50 @@ function expand(value, folders) {
113
165
  return value.replace(/\$\{(\w+)\}/g, (_, name) => name === "CLAUDE_PLUGIN_ROOT" ? folders.root : name === "CLAUDE_PLUGIN_DATA" ? folders.data : (process.env[name] ?? ""));
114
166
  }
115
167
  /**
116
- * Why one of an extension's servers can't run through the launcher (only Node, its script
117
- * first, inside the extension), or how the kit starts it.
168
+ * Why one of an extension's servers can't run through the launcher (Node, its script first,
169
+ * inside the extension; or npx with a package at an exact version, from the cache), or how the
170
+ * kit starts it.
118
171
  */
119
- function serverLaunch(name, server, folders) {
172
+ function serverLaunch(name, server, folders, packages = {}) {
120
173
  const { root } = folders;
174
+ // Its folders as variables too, as Claude Code gives them to a plugin's servers.
175
+ const declared = Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, expand(String(value), folders)]));
176
+ const env = { CLAUDE_PLUGIN_ROOT: root, CLAUDE_PLUGIN_DATA: folders.data, ...declared };
177
+ const npx = npxServer(server);
178
+ if (typeof npx === "string")
179
+ return `${npx} (its server "${name}")`;
180
+ if (npx) {
181
+ const locked = packages.locked?.[packageSpec(npx)];
182
+ if (!packages.cache || !locked)
183
+ return `runs ${packageSpec(npx)}, which wasn't fetched when it was installed: install it again`;
184
+ const entry = path.join(cachedPackageDir(packages.cache, npx.name, npx.version), locked.bin);
185
+ return { entry, args: npx.args.map((arg) => expand(arg, folders)), env };
186
+ }
121
187
  if (!/^node(\.exe)?$/i.test(path.basename(server.command)))
122
- return `runs its server "${name}" with "${server.command}": only Node servers can be installed`;
188
+ return `runs its server "${name}" with "${server.command}": only Node servers, or npx with a package's exact version, can be installed`;
123
189
  const [script, ...args] = (server.args ?? []).map((arg) => expand(arg, folders));
124
190
  if (!script)
125
191
  return `gives its server "${name}" no script to run`;
126
192
  const entry = path.resolve(root, script);
127
193
  if (path.relative(root, entry).startsWith(".."))
128
194
  return `runs its server "${name}" from outside its folder (${script})`;
129
- // Its folders as variables too, as Claude Code gives them to a plugin's servers.
130
- const declared = Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, expand(String(value), folders)]));
131
- return { entry, args, env: { CLAUDE_PLUGIN_ROOT: root, CLAUDE_PLUGIN_DATA: folders.data, ...declared } };
195
+ return { entry, args, env };
132
196
  }
133
- /** Why an extension's servers can't run, or nothing: each must be Node, with its script there. */
134
- async function serversProblem(manifest, root) {
197
+ /**
198
+ * Why an extension's servers can't run, or nothing: each Node with its script there, or npx with
199
+ * a package at an exact version (in the cache, once `packages` is given: when it loads).
200
+ */
201
+ async function serversProblem(manifest, root, packages) {
135
202
  for (const [name, server] of Object.entries(manifest.servers)) {
203
+ const npx = npxServer(server);
204
+ if (typeof npx === "string")
205
+ return `${npx} (its server "${name}")`;
206
+ if (npx) {
207
+ const problem = packages && (await cachedPackageProblem(packages.cache, npx, packages.locked?.[packageSpec(npx)]));
208
+ if (problem)
209
+ return problem;
210
+ continue;
211
+ }
136
212
  const launch = serverLaunch(name, server, { root, data: "" });
137
213
  if (typeof launch === "string")
138
214
  return launch;
@@ -141,7 +217,28 @@ async function serversProblem(manifest, root) {
141
217
  }
142
218
  return undefined;
143
219
  }
144
- /** SHA-256 over an extension's files (their paths and contents, in order), `.git` aside. */
220
+ /** Fetches the packages an extension's npx servers run into the cache (#54), as its lock records them. */
221
+ async function fetchServerPackages(manifest, cache) {
222
+ const packages = {};
223
+ for (const server of Object.values(manifest.servers)) {
224
+ const npx = npxServer(server);
225
+ if (!npx || typeof npx === "string")
226
+ continue;
227
+ packages[packageSpec(npx)] = await cachePackage(cache, npx.name, npx.version);
228
+ }
229
+ return packages;
230
+ }
231
+ /** A text file's bytes with CRLF as LF (latin1 maps each byte to itself); a binary one's (a NUL byte in it) as they are. */
232
+ function withoutCarriageReturns(bytes) {
233
+ if (bytes.includes(0) || !bytes.includes(13))
234
+ return bytes;
235
+ return Buffer.from(bytes.toString("latin1").replace(/\r\n/g, "\n"), "latin1");
236
+ }
237
+ /**
238
+ * SHA-256 over an extension's files (their paths and contents, in order), `.git` aside. A text
239
+ * file's line endings don't count (CRLF is taken as LF): git checks the same commit out with CRLF
240
+ * on one computer and LF on another, and a workspace's lock must match on both (#57).
241
+ */
145
242
  export async function hashExtension(dir) {
146
243
  const hash = createHash("sha256");
147
244
  async function walk(relative) {
@@ -151,7 +248,7 @@ export async function hashExtension(dir) {
151
248
  if (entry.isDirectory())
152
249
  await walk(child);
153
250
  else if (entry.isFile())
154
- hash.update(`${child}\0`).update(await readFile(path.join(dir, child))).update("\0");
251
+ hash.update(`${child}\0`).update(withoutCarriageReturns(await readFile(path.join(dir, child)))).update("\0");
155
252
  }
156
253
  }
157
254
  await walk("");
@@ -199,10 +296,12 @@ export async function listInstalled(dirs) {
199
296
  const scopeDir = dirs[scope];
200
297
  if (!scopeDir)
201
298
  continue;
299
+ const privateDir = scopePrivateDir(dirs, scope);
300
+ const store = codeStoreDir(dirs);
202
301
  for (const [name, lock] of Object.entries(await readLock(scopeDir))) {
203
- const dir = lock.linked ? lock.source : path.join(scopeDir, name);
302
+ const dir = lock.linked ? lock.source : lock.stored && store ? storedCodeDir(store, name, lock.sha256) : path.join(scopeDir, name);
204
303
  const manifest = await readExternalManifest(dir).catch(() => undefined);
205
- installed.push({ name, scope, scopeDir, dir, dataDir: extensionDataDir(scopeDir, name), lock, shadowed: installed.some((other) => other.name === name), ...(manifest ? { manifest } : {}) });
304
+ installed.push({ name, scope, scopeDir, dir, dataDir: extensionDataDir(privateDir, name), privateDir, lock, shadowed: installed.some((other) => other.name === name), ...(manifest ? { manifest } : {}) });
206
305
  }
207
306
  }
208
307
  return installed;
@@ -225,67 +324,101 @@ function manifestToolLabels(manifest) {
225
324
  }));
226
325
  }
227
326
  /** The extension an installed one makes: its servers started through the launcher, its rules from its manifest. */
228
- function externalExtension(installed, manifest) {
327
+ function externalExtension(installed, manifest, cache, unavailable = []) {
229
328
  return {
230
329
  name: manifest.name,
231
330
  plugin: installed.dir,
232
331
  external: true,
233
332
  installedIn: installed.scopeDir,
333
+ ...(unavailable.length ? { unavailable } : {}),
234
334
  async contribute() {
235
335
  const servers = {};
236
336
  if (Object.keys(manifest.servers).length)
237
337
  await mkdir(installed.dataDir, { recursive: true });
238
338
  for (const [name, server] of Object.entries(manifest.servers)) {
239
- const launch = serverLaunch(name, server, { root: installed.dir, data: installed.dataDir });
339
+ const launch = serverLaunch(name, server, { root: installed.dir, data: installed.dataDir }, { cache, locked: installed.lock.packages });
240
340
  if (typeof launch === "string")
241
341
  continue; // checked when it loaded
242
342
  // Its `env` reaches it (the SDK merges it with the agent's); the launcher removes the rest.
243
343
  // Inside Electron, process.execPath is the app itself: ELECTRON_RUN_AS_NODE makes it run
244
344
  // the launcher as Node (the launcher removes it, with the rest, once it runs).
245
345
  const env = process.versions.electron ? { ...launch.env, ELECTRON_RUN_AS_NODE: "1" } : launch.env;
246
- servers[name] = { type: "stdio", command: process.execPath, args: [extensionLauncherPath(), launch.entry, JSON.stringify(Object.keys(launch.env)), ...launch.args], env };
346
+ // The launcher's own spec: the variables it keeps, and where its options' values are, read
347
+ // when it starts the server, so a changed value reaches a server started again (#55).
348
+ const spec = {
349
+ keep: Object.keys(launch.env),
350
+ ...(Object.keys(manifest.userConfig).length
351
+ ? { options: { lock: path.join(installed.scopeDir, LOCK_FILE), secrets: extensionSecretsPath(installed.privateDir, installed.name), name: installed.name, defaults: optionDefaults(manifest.userConfig) } }
352
+ : {}),
353
+ };
354
+ servers[name] = { type: "stdio", command: process.execPath, args: [extensionLauncherPath(), launch.entry, JSON.stringify(spec), ...launch.args], env };
247
355
  }
248
356
  return {
249
357
  mcpServers: servers,
250
- readOnlyTools: Object.keys(manifest.servers).flatMap((server) => manifest.readOnlyTools.map((tool) => `mcp__${server}__${tool}`)),
358
+ // Its manifest's list, or its servers' own word: the server is the code the kit trusts
359
+ // least, so its hints only fill the list's absence (ADR-025).
360
+ ...(manifest.readOnlyTools
361
+ ? { readOnlyTools: Object.keys(manifest.servers).flatMap((server) => manifest.readOnlyTools.map((tool) => `mcp__${server}__${tool}`)) }
362
+ : { readOnlyTools: [], readOnlyHintServers: Object.keys(servers) }),
251
363
  toolLabels: manifestToolLabels(manifest),
252
364
  ...(manifest.help ? { helpLines: [manifest.help] } : {}),
253
365
  };
254
366
  },
255
367
  };
256
368
  }
369
+ /** Its options' defaults, for the launcher. */
370
+ function optionDefaults(declared) {
371
+ return Object.fromEntries(Object.entries(declared).flatMap(([key, option]) => (option.default === undefined ? [] : [[key, option.default]])));
372
+ }
373
+ /** Why its options keep it from running (a server naming one it doesn't declare, a required one without a value), or nothing. */
374
+ function optionsProblem(manifest, values) {
375
+ const named = optionsNamed(Object.values(manifest.servers).flatMap((server) => [server.command, ...(server.args ?? []), ...Object.values(server.env ?? {}).map(String)]));
376
+ const undeclared = named.filter((key) => !manifest.userConfig[key]);
377
+ if (undeclared.length)
378
+ return `names ${undeclared.map((key) => "${user_config." + key + "}").join(", ")}, which its userConfig doesn't declare`;
379
+ const missing = missingOptions(manifest.userConfig, values);
380
+ if (missing.length)
381
+ return `needs ${missing.map((key) => `${key} (${manifest.userConfig[key].title})`).join(", ")} set: \`extension config ${manifest.name} ${missing[0]}=<value>\``;
382
+ return undefined;
383
+ }
257
384
  /** The installed extensions a session runs with, and those it can't (with why). Disabled ones are neither. */
258
385
  export async function loadExternalExtensions(dirs) {
259
386
  const extensions = [];
260
387
  const off = [];
388
+ const untrusted = [];
389
+ const cache = packageCacheDir(dirs);
261
390
  for (const installed of await listInstalled(dirs)) {
262
391
  if (installed.shadowed || !installed.lock.enabled)
263
392
  continue;
264
- const reason = await problem(installed);
393
+ // A workspace's own: only once the person trusts it there (#57); the agent's never asks.
394
+ if (installed.scope === "project" && dirs.agent && !(await isTrusted(dirs.agent, installed.scopeDir, installed.name, installed.lock))) {
395
+ untrusted.push(installed);
396
+ off.push({ name: installed.name, reason: "comes with this workspace and isn't trusted here yet: `extension trust`, or the chat asks" });
397
+ continue;
398
+ }
399
+ const reason = await problem(installed, cache);
265
400
  if (typeof reason === "string")
266
401
  off.push({ name: installed.name, reason });
267
402
  else
268
- extensions.push(externalExtension(installed, reason));
403
+ extensions.push(externalExtension(installed, reason.manifest, cache, reason.unavailable));
269
404
  }
270
- return { extensions, off };
405
+ return { extensions, off, untrusted };
271
406
  }
272
407
  /** Why an installed extension can't load, or its manifest when it can. */
273
408
  /** Whether a program is on the `PATH`, as a shell would find it (with `PATHEXT`'s extensions on Windows). */
274
409
  export async function onPath(program, env = process.env) {
275
- const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
276
- const extensions = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").map((extension) => extension.toLowerCase())] : [""];
277
- for (const dir of dirs) {
278
- for (const extension of extensions) {
279
- const found = await stat(path.join(dir, `${program}${extension}`)).then((entry) => entry.isFile(), () => false);
280
- if (found)
281
- return true;
282
- }
283
- }
284
- return false;
410
+ return (await findOnPath(program, env)) !== undefined;
411
+ }
412
+ /** A requirement this computer lacks, as the person reads it: what it is, what for, and how to install it. */
413
+ export function missingRequirement(check) {
414
+ const needed = check.requirement.for.length ? ` for ${check.requirement.for.join(", ")}` : "";
415
+ return `needs ${describeMissing(check)}${needed}`;
285
416
  }
286
- async function problem(installed) {
417
+ async function problem(installed, cache) {
287
418
  let manifest;
288
419
  try {
420
+ if (installed.lock.stored && !(await stat(installed.dir).catch(() => null))?.isDirectory())
421
+ return "isn't on this computer yet (its workspace says which version it uses): `extension restore` gets it";
289
422
  if (!installed.lock.linked && (await hashExtension(installed.dir)) !== installed.lock.sha256)
290
423
  return "has files that changed since it was installed: install it again to trust them";
291
424
  manifest = await readExternalManifest(installed.dir);
@@ -297,13 +430,22 @@ async function problem(installed) {
297
430
  return `is installed as "${installed.name}" but its manifest says "${manifest.name}"`;
298
431
  if (manifest.kit && !inKitRange(agentKitVersion(), manifest.kit))
299
432
  return `works with agent-kit ${manifest.kit}, and this agent runs ${agentKitVersion()}`;
300
- const missing = [];
301
- for (const program of manifest.needs)
302
- if (!(await onPath(program)))
303
- missing.push(program);
304
- if (missing.length)
305
- return `needs ${missing.join(", ")}, which ${missing.length === 1 ? "isn't" : "aren't"} on this computer (on the PATH)`;
306
- return (await serversProblem(manifest, installed.dir)) ?? manifest;
433
+ const requirements = requirementsProblem(manifest.requirements, manifest.provides);
434
+ if (requirements)
435
+ return requirements;
436
+ // A program the whole extension needs keeps it off; one only some capabilities need, those (#63).
437
+ const checks = (await checkRequirements(manifest.requirements)).filter((check) => !check.ok);
438
+ const whole = checks.filter((check) => !check.requirement.for.length);
439
+ if (whole.length)
440
+ return whole.map((check) => missingRequirement(check)).join("; ");
441
+ const unavailable = checks.map((check) => ({ capabilities: check.requirement.for, reason: `needs ${describeMissing(check)}` }));
442
+ const capabilities = await capabilitiesProblem(installed.dir, manifest.capabilities);
443
+ if (capabilities)
444
+ return capabilities;
445
+ const options = optionsProblem(manifest, await readOptionValues(installed.privateDir, installed.name, manifest.userConfig, installed.lock.options));
446
+ if (options)
447
+ return options;
448
+ return (await serversProblem(manifest, installed.dir, { cache, locked: installed.lock.packages })) ?? { manifest, unavailable };
307
449
  }
308
450
  const exec = promisify(execFile);
309
451
  /** Whether `source` names a git repository rather than a folder. */
@@ -316,7 +458,9 @@ export function isGitSource(source) {
316
458
  * manifest, and locks it, enabled, with the marketplace it came from when it did (`origin`, for a
317
459
  * temporary folder, is what the lock says it came from). An extension with the same name in that
318
460
  * scope is replaced; its data folder stays. With `link`, a folder is registered where it is
319
- * instead, to develop it: not copied, and its files not checked.
461
+ * instead, to develop it: not copied, and its files not checked. The packages its npx servers
462
+ * run are fetched into `packages` (#54), the agent's package cache (`packageCacheDir()`; by
463
+ * default the scope's).
320
464
  */
321
465
  export async function addExtension(source, scopeDir, options = {}) {
322
466
  if (options.link && isGitSource(source))
@@ -340,33 +484,55 @@ export async function addExtension(source, scopeDir, options = {}) {
340
484
  if (options.subdir)
341
485
  from = path.join(from, options.subdir);
342
486
  const manifest = await readExternalManifest(from);
343
- const servers = await serversProblem(manifest, from);
487
+ const servers = (await serversProblem(manifest, from)) ?? (await capabilitiesProblem(from, manifest.capabilities)) ?? requirementsProblem(manifest.requirements, manifest.provides);
344
488
  if (servers)
345
489
  throw new Error(`${manifest.name} can't be installed: it ${servers}.`);
490
+ // What it needs and this computer lacks: said, never installed, and it installs anyway (#63).
491
+ const missing = (await checkRequirements(manifest.requirements)).filter((check) => !check.ok).map((check) => missingRequirement(check));
346
492
  const target = path.join(scopeDir, manifest.name);
347
493
  // Replacing what's installed there would delete the source itself.
348
494
  if (path.resolve(from) === path.resolve(target))
349
495
  throw new Error(`${from} is where ${manifest.name} is installed: install it from another folder.`);
496
+ const packages = await fetchServerPackages(manifest, options.packages ?? path.join(scopeDir, ".npm"));
497
+ const locked = Object.keys(packages).length ? { packages } : {};
350
498
  const lock = await readLock(scopeDir);
351
499
  const replaced = Boolean(lock[manifest.name]);
500
+ // Reinstalled, it keeps the values it had for the options it still declares, as it keeps its data.
501
+ const kept = Object.fromEntries(Object.entries(lock[manifest.name]?.options ?? {}).filter(([key]) => manifest.userConfig[key] && !manifest.userConfig[key].sensitive));
502
+ if (Object.keys(kept).length)
503
+ locked.options = kept;
352
504
  await rm(target, { recursive: true, force: true });
353
505
  await mkdir(scopeDir, { recursive: true });
506
+ const origin = options.origin ?? (isGitSource(source) ? source : from);
507
+ const provenance = { ...(commit ? { commit } : {}), ...(options.subdir ? { subdir: options.subdir } : {}), ...(options.marketplace ? { marketplace: options.marketplace } : {}) };
508
+ if (options.store && !options.link) {
509
+ // By content, in the agent's store: two workspaces with the same version share one copy.
510
+ const sha256 = await hashExtension(from);
511
+ const stored = storedCodeDir(options.store, manifest.name, sha256);
512
+ if (!(await stat(stored).catch(() => null))?.isDirectory() || (await hashExtension(stored)) !== sha256) {
513
+ await rm(stored, { recursive: true, force: true });
514
+ await cp(from, stored, { recursive: true, filter: (file) => path.basename(file) !== ".git" });
515
+ }
516
+ lock[manifest.name] = { source: origin, ...provenance, sha256, stored: true, ...locked, enabled: true, installed: new Date().toISOString() };
517
+ await writeLock(scopeDir, lock);
518
+ return { name: manifest.name, replaced, missing, ...(commit ? { commit } : {}) };
519
+ }
354
520
  if (options.link) {
355
- lock[manifest.name] = { source: from, linked: true, sha256: "", enabled: true, installed: new Date().toISOString() };
521
+ lock[manifest.name] = { source: from, linked: true, sha256: "", ...locked, enabled: true, installed: new Date().toISOString() };
356
522
  await writeLock(scopeDir, lock);
357
- return { name: manifest.name, replaced };
523
+ return { name: manifest.name, replaced, missing };
358
524
  }
359
525
  await cp(from, target, { recursive: true, filter: (file) => path.basename(file) !== ".git" });
360
526
  lock[manifest.name] = {
361
- source: options.origin ?? (isGitSource(source) ? `${source}${options.subdir ? ` (${options.subdir})` : ""}` : from),
362
- ...(commit ? { commit } : {}),
363
- ...(options.marketplace ? { marketplace: options.marketplace } : {}),
527
+ source: origin,
528
+ ...provenance,
364
529
  sha256: await hashExtension(target),
530
+ ...locked,
365
531
  enabled: true,
366
532
  installed: new Date().toISOString(),
367
533
  };
368
534
  await writeLock(scopeDir, lock);
369
- return { name: manifest.name, replaced, ...(commit ? { commit } : {}) };
535
+ return { name: manifest.name, replaced, missing, ...(commit ? { commit } : {}) };
370
536
  }
371
537
  finally {
372
538
  if (clone)
@@ -378,13 +544,16 @@ export async function addExtension(source, scopeDir, options = {}) {
378
544
  * where it is), and its data folder with `deleteData`; false when it wasn't there.
379
545
  */
380
546
  export async function removeExtension(scopeDir, name, options = {}) {
547
+ const privateDir = options.privateDir ?? scopeDir;
381
548
  const lock = await readLock(scopeDir);
382
549
  if (!lock[name])
383
550
  return false;
384
551
  delete lock[name];
385
552
  await rm(path.join(scopeDir, name), { recursive: true, force: true });
553
+ // A stored one's code stays in the store: another workspace may use the same version.
386
554
  if (options.deleteData)
387
- await rm(extensionDataDir(scopeDir, name), { recursive: true, force: true });
555
+ await rm(extensionDataDir(privateDir, name), { recursive: true, force: true });
556
+ await removeSecrets(privateDir, name);
388
557
  await writeLock(scopeDir, lock);
389
558
  return true;
390
559
  }
@@ -409,3 +578,46 @@ export async function setExtensionEnabled(scopeDir, name, enabled) {
409
578
  await writeLock(scopeDir, lock);
410
579
  return true;
411
580
  }
581
+ /**
582
+ * Sets an installed extension's options (#55), each from what the person typed (`undefined`
583
+ * clears it), checked against its `userConfig`: sensitive ones into the scope's secrets, the rest
584
+ * into its lock. A running session's servers take them when they start again.
585
+ */
586
+ export async function setExtensionOptions(where, name, typed) {
587
+ // A scope's folder (the agent's, whose code and secrets are in it), or an installed one, which knows where they are.
588
+ const scopeDir = typeof where === "string" ? where : where.scopeDir;
589
+ const privateDir = typeof where === "string" ? where : where.privateDir;
590
+ const lock = await readLock(scopeDir);
591
+ const entry = lock[name];
592
+ if (!entry)
593
+ throw new Error(`${name} isn't installed in ${scopeDir}.`);
594
+ const manifest = (typeof where === "string" ? undefined : where.manifest) ?? (await readExternalManifest(entry.linked ? entry.source : path.join(scopeDir, name)));
595
+ const changes = {};
596
+ for (const [key, text] of Object.entries(typed)) {
597
+ const option = manifest.userConfig[key];
598
+ if (!option)
599
+ throw new Error(`${name} has no option "${key}"${Object.keys(manifest.userConfig).length ? `: it has ${Object.keys(manifest.userConfig).join(", ")}` : ""}.`);
600
+ try {
601
+ changes[key] = text === undefined ? undefined : parseOptionValue(option, text);
602
+ }
603
+ catch (error) {
604
+ throw new Error(`${key}: ${error instanceof Error ? error.message : String(error)}.`, { cause: error });
605
+ }
606
+ }
607
+ const options = await storeOptionValues(privateDir, name, manifest.userConfig, entry.options ?? {}, changes);
608
+ lock[name] = { ...entry, options };
609
+ if (!Object.keys(options).length)
610
+ delete lock[name].options;
611
+ await writeLock(scopeDir, lock);
612
+ }
613
+ /** An installed extension's options, as they are now. */
614
+ export async function extensionOptionStates(installed) {
615
+ const declared = installed.manifest?.userConfig ?? {};
616
+ const values = await readOptionValues(installed.privateDir, installed.name, declared, installed.lock.options);
617
+ const secrets = await readOptionValues(installed.privateDir, installed.name, Object.fromEntries(Object.entries(declared).map(([key, option]) => [key, { ...option, default: undefined }])), installed.lock.options);
618
+ return Object.entries(declared).map(([key, option]) => {
619
+ const value = values[key];
620
+ const own = secrets[key];
621
+ return { key, option, ...(value !== undefined && !option.sensitive ? { value } : {}), set: value !== undefined && value !== "", isDefault: value !== undefined && own === undefined };
622
+ });
623
+ }
@@ -9,6 +9,8 @@ export interface PlanScope {
9
9
  isReadOnlyTool?(toolName: string, input: Record<string, unknown>): boolean;
10
10
  /** The enabled extensions' MCP tools that only read (`mcp__<server>__<tool>`). */
11
11
  readOnlyTools?: readonly string[];
12
+ /** Whether an MCP tool's own server marks it `readOnlyHint`, for the extensions whose manifest lists none (readOnlyHints.ts). */
13
+ isReadOnlyHinted?(toolName: string): Promise<boolean>;
12
14
  }
13
15
  /**
14
16
  * The reason to deny `toolName` with `input` in plan mode, or `undefined` to let it through.
@@ -41,6 +41,9 @@ export function createPlanGate(scope, isActive) {
41
41
  const reason = checkPlanScope(scope, pre.tool_name, (pre.tool_input ?? {}));
42
42
  if (!reason)
43
43
  return {};
44
+ // Asked last, and only for an MCP tool nothing else let through: it's a call to the session.
45
+ if (pre.tool_name.startsWith("mcp__") && (await scope.isReadOnlyHinted?.(pre.tool_name)))
46
+ return {};
44
47
  return {
45
48
  hookSpecificOutput: {
46
49
  hookEventName: pre.hook_event_name,
@@ -1,3 +1,4 @@
1
+ import type { FormAnswer, FormRequest } from "./elicitation.js";
1
2
  /** What a checkpoint shows the person: a title, detail lines and, in a plain terminal, the question. */
2
3
  export interface ApprovalPrompt {
3
4
  title: string;
@@ -43,6 +44,12 @@ export interface InteractionPort {
43
44
  * `askDecision()`, with the options numbered in the prompt.
44
45
  */
45
46
  askChoice?(prompt: ApprovalPrompt, choice: ChoiceSettings, signal: AbortSignal): Promise<string>;
47
+ /**
48
+ * A form an MCP server asks for (elicitation, #58), whole: its fields, and the person's
49
+ * values, or that they declined or cancelled. Optional: without it the kit asks field by
50
+ * field through the other methods. Never written to a log: the answer is the server's.
51
+ */
52
+ askForm?(form: FormRequest, signal: AbortSignal): Promise<FormAnswer>;
46
53
  /** A one-way message for the person. */
47
54
  notify(message: string): void;
48
55
  }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionsStatus } from "./session.js";
2
+ import { type ExtensionCapability, type UnavailableCapability } from "./extensions.js";
2
3
  import type { SessionLive, StaticSessionFacts } from "./sessionFacts.js";
3
4
  import type { ToolLabels } from "./toolLabels.js";
4
5
  /**
@@ -43,6 +44,9 @@ export interface LiveExtension {
43
44
  agents: string[];
44
45
  description: string;
45
46
  provides: string[];
47
+ capabilities: ExtensionCapability[];
48
+ /** What it can't do on this computer (#63). */
49
+ unavailable: UnavailableCapability[];
46
50
  help: string[];
47
51
  }
48
52
  /** The session's own lists the switch keeps in step: the same objects the gates, the chat and the view read. */
@@ -63,3 +67,14 @@ export interface LiveTargets {
63
67
  export type ExtensionSwitch = (name: string, enabled: boolean) => Promise<boolean>;
64
68
  /** The switch for a session's installed extensions. */
65
69
  export declare function createExtensionSwitch(extensions: readonly LiveExtension[], targets: LiveTargets): ExtensionSwitch;
70
+ /**
71
+ * Restarts `name`'s MCP servers in the running session, so they take its options as they are now
72
+ * (#55): the launcher reads them when it starts a server. `reconnectMcpServer()` ends a server's
73
+ * process and starts a new one with the session's config (confirmed empirically; `setMcpServers()`
74
+ * replaced every server of the session and left the old process running). False when it can't
75
+ * be done here (not running in this session, or the session hasn't started): the next session
76
+ * takes them.
77
+ */
78
+ export type ExtensionRestart = (name: string) => Promise<boolean>;
79
+ /** The restart for a session's installed extensions. */
80
+ export declare function createExtensionRestart(extensions: readonly LiveExtension[], targets: LiveTargets): ExtensionRestart;
@@ -1,6 +1,8 @@
1
1
  import { cp, lstat, mkdir, readdir, rm, symlink, unlink } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { aboutExtension, describeCapability } from "./extensions.js";
3
4
  import { hashExtension, listInstalled, readLock } from "./externalExtensions.js";
5
+ import { cachedPackagesIntact, packageCacheDir } from "./npxPackages.js";
4
6
  /**
5
7
  * Turning an installed extension off and on in a running session (#49), without reopening it.
6
8
  * Only one that was running when the session opened: its MCP servers are toggled
@@ -68,6 +70,9 @@ export function createExtensionSwitch(extensions, targets) {
68
70
  const lock = (await readLock(extension.scopeDir))[name];
69
71
  if (!lock || (!lock.linked && lock.sha256 !== (await hashExtension(extension.installedDir))))
70
72
  return false;
73
+ // The packages its npx servers run (#54), checked whether it's linked or not.
74
+ if (!(await cachedPackagesIntact(packageCacheDir(targets.status.dirs), lock.packages)))
75
+ return false;
71
76
  await mountPlugin(extension.installedDir, extension.pluginMount);
72
77
  }
73
78
  else {
@@ -85,6 +90,24 @@ export function createExtensionSwitch(extensions, targets) {
85
90
  return true;
86
91
  };
87
92
  }
93
+ /** The restart for a session's installed extensions. */
94
+ export function createExtensionRestart(extensions, targets) {
95
+ return async (name) => {
96
+ const extension = extensions.find((candidate) => candidate.name === name);
97
+ const reconnect = targets.controls().reconnectMcpServer;
98
+ if (!extension || !reconnect || !extension.servers.length)
99
+ return false;
100
+ try {
101
+ for (const server of extension.servers)
102
+ await reconnect(server);
103
+ return true;
104
+ }
105
+ catch {
106
+ // Turned off in the session: it starts with them when it's turned on again.
107
+ return false;
108
+ }
109
+ };
110
+ }
88
111
  /** The kit's lists, as the extension is now. */
89
112
  function follow(extension, enabled, targets) {
90
113
  const without = (list, items) => {
@@ -100,9 +123,17 @@ function follow(extension, enabled, targets) {
100
123
  targets.allowedSubagentTypes.push(...extension.agents);
101
124
  Object.assign(targets.toolLabels, extension.toolLabels);
102
125
  status.active.push(extension.name);
103
- status.about[extension.name] = { description: extension.description, provides: extension.provides };
126
+ status.about[extension.name] = aboutExtension({ name: extension.name, description: extension.description, provides: extension.provides, requires: [], capabilities: extension.capabilities }, extension.servers, facts.language, extension.unavailable);
104
127
  status.inactive = status.inactive.filter((entry) => entry.name !== extension.name);
105
- facts.extensions.active.push({ name: extension.name, description: extension.description, provides: extension.provides, servers: extension.servers, help: extension.help });
128
+ facts.extensions.active.push({
129
+ name: extension.name,
130
+ description: extension.description,
131
+ provides: extension.provides,
132
+ capabilities: extension.capabilities.map((capability) => describeCapability(capability, "en")),
133
+ unavailable: extension.unavailable.map(({ capability, reason }) => ({ ...describeCapability(capability, "en"), reason })),
134
+ servers: extension.servers,
135
+ help: extension.help,
136
+ });
106
137
  facts.extensions.inactive = facts.extensions.inactive.filter((entry) => entry.name !== extension.name);
107
138
  }
108
139
  else {