@falkenslab/agent-kit 0.20.0 → 0.21.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.
@@ -12,8 +12,15 @@ if (!entry) {
12
12
  process.exit(1);
13
13
  }
14
14
 
15
- // What any process may need to run, on Windows and elsewhere; never a credential.
16
- const SYSTEM = ["PATH", "PATHEXT", "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "TEMP", "TMP", "TMPDIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "LANG", "LC_ALL", "TZ"];
15
+ // What any process may need to run, on Windows and elsewhere; never a credential. Where programs
16
+ // are installed and the desktop session too: a browser is found through PROGRAMFILES on Windows
17
+ // (Playwright found no Edge without them, #52) and opens a window through DISPLAY on Linux.
18
+ const SYSTEM = [
19
+ "PATH", "PATHEXT", "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "TEMP", "TMP", "TMPDIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "LANG", "LC_ALL", "TZ",
20
+ "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMW6432", "COMMONPROGRAMFILES", "COMMONPROGRAMFILES(X86)", "COMMONPROGRAMW6432", "PROGRAMDATA", "ALLUSERSPROFILE",
21
+ "HOMEDRIVE", "HOMEPATH", "USERNAME", "USER", "LOGNAME", "OS", "PROCESSOR_ARCHITECTURE", "NUMBER_OF_PROCESSORS",
22
+ "DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "DBUS_SESSION_BUS_ADDRESS",
23
+ ];
17
24
  const allowed = new Set([...SYSTEM, ...JSON.parse(declared)].map((name) => name.toUpperCase()));
18
25
  for (const name of Object.keys(process.env)) {
19
26
  if (!allowed.has(name.toUpperCase())) delete process.env[name];
@@ -3,7 +3,7 @@ import { setExtensionEnabled } from "../core/externalExtensions.js";
3
3
  import { t } from "../core/messages/index.js";
4
4
  /** One line for an installed extension: name and version, scope, on or off, its author, where it came from. */
5
5
  export function describe(extension) {
6
- const state = extension.shadowed ? "not used (the project's wins)" : extension.lock.enabled ? "enabled" : "disabled";
6
+ const state = `${extension.shadowed ? "not used (the project's wins)" : extension.lock.enabled ? "enabled" : "disabled"}${extension.lock.linked ? ", linked" : ""}`;
7
7
  // From a marketplace, its name there (`jokebook@shipyard`): the copy it came from is the kit's business.
8
8
  const where = extension.lock.commit ? `${extension.lock.source} @ ${extension.lock.commit.slice(0, 12)}` : path.normalize(extension.lock.source);
9
9
  const source = extension.lock.marketplace ? `${extension.lock.marketplace}${extension.lock.commit ? ` @ ${extension.lock.commit.slice(0, 12)}` : ""}` : where;
@@ -82,6 +82,8 @@ export interface Extension {
82
82
  plugin: string;
83
83
  /** Installed rather than shipped in a package (#37): its subagents get no `Bash`, and no plugin hook runs. */
84
84
  external?: boolean;
85
+ /** An installed one's scope folder, where its lock is (its plugin is elsewhere when it's linked). */
86
+ installedIn?: string;
85
87
  /** Why it can't run in this session (e.g. "needs a folder (`dir`)"), or `undefined` when it can. */
86
88
  missing?(context: ExtensionContext): string | undefined;
87
89
  /** What it brings to the session, once it's active. */
@@ -9,7 +9,9 @@ import { type ToolPhrase } from "./messages/index.js";
9
9
  * extension is a Claude Code plugin as it is (#38): its MCP servers come from its `.mcp.json`,
10
10
  * and the kit starts each with Node through a launcher that leaves it only the system's
11
11
  * variables and its own `env`. The `"agent-kit"` key of its `plugin.json` is optional: it adds
12
- * what the kit's gates and chat can use (capabilities, read-only tools, labels, help).
12
+ * what the kit's gates and chat can use (capabilities, read-only tools, labels, help). Each has a
13
+ * data folder of its own (`${CLAUDE_PLUGIN_DATA}`, as Claude Code gives a plugin), kept across
14
+ * sessions and updates; one being developed is linked rather than copied (#52).
13
15
  */
14
16
  /** The two folders an agent installs extensions into; either may be left out. */
15
17
  export interface ExtensionDirs {
@@ -24,7 +26,7 @@ export type ExtensionScope = keyof ExtensionDirs;
24
26
  export interface PluginServer {
25
27
  command: string;
26
28
  args?: string[];
27
- /** Its variables: a value may take the agent's with `${VAR}`. */
29
+ /** Its variables: a value may take the agent's with `${VAR}`, and its folders with `${CLAUDE_PLUGIN_ROOT}` and `${CLAUDE_PLUGIN_DATA}`. */
28
30
  env?: Record<string, string>;
29
31
  }
30
32
  /** How the chat shows one of its tools in one language: `{field}` takes the call's input. */
@@ -68,19 +70,31 @@ export interface ExternalManifest {
68
70
  }
69
71
  /** One extension in a scope's lock. */
70
72
  export interface LockEntry {
71
- /** Where it came from: a folder, or a git URL (with `#ref` when given). */
73
+ /** Where it came from: a folder, or a git URL (with `#ref` when given). A linked one runs from there. */
72
74
  source: string;
75
+ /**
76
+ * Linked rather than copied (`extension add --link`), to develop it: it runs from its folder,
77
+ * whose files aren't checked, so a rebuild takes effect when its server starts again.
78
+ */
79
+ linked?: boolean;
73
80
  /** The commit it was cloned at, for a git source. */
74
81
  commit?: string;
75
82
  /** The marketplace it was installed from (`<name>@<marketplace>`), if any. */
76
83
  marketplace?: string;
77
- /** SHA-256 over its files, checked when it loads. */
84
+ /** SHA-256 over its files, checked when it loads; empty for a linked one. */
78
85
  sha256: string;
79
86
  enabled: boolean;
80
87
  /** When it was installed, ISO 8601 in UTC. */
81
88
  installed: string;
82
89
  }
83
90
  export declare const LOCK_FILE = "extensions.lock.json";
91
+ /**
92
+ * An installed extension's data folder: `${CLAUDE_PLUGIN_DATA}` in its servers' arguments and
93
+ * variables (a browser profile, a cache), in its scope, outside its plugin and its hash, so it's
94
+ * kept across sessions and updates. Created when a session starts it; removing the extension
95
+ * asks before deleting it.
96
+ */
97
+ export declare function extensionDataDir(scopeDir: string, name: string): string;
84
98
  /** A scope's lock: its extensions by name. */
85
99
  export declare function readLock(scopeDir: string): Promise<Record<string, LockEntry>>;
86
100
  /** Reads an extension's manifest, checking what the kit needs from it. */
@@ -96,7 +110,12 @@ export declare function inKitRange(version: string, range: string): boolean;
96
110
  export interface InstalledExtension {
97
111
  name: string;
98
112
  scope: ExtensionScope;
113
+ /** Its scope's folder, where its lock is. */
114
+ scopeDir: string;
115
+ /** Its plugin: in its scope's folder, or where it is when it's linked. */
99
116
  dir: string;
117
+ /** Its data folder (`${CLAUDE_PLUGIN_DATA}`), which may not exist yet. */
118
+ dataDir: string;
100
119
  lock: LockEntry;
101
120
  /** Also installed in the project's scope, which wins: this one isn't used. */
102
121
  shadowed: boolean;
@@ -125,19 +144,28 @@ export declare function isGitSource(source: string): boolean;
125
144
  * a commit, and `subdir` for one inside it): copies its files (never runs a script), checks its
126
145
  * manifest, and locks it, enabled, with the marketplace it came from when it did (`origin`, for a
127
146
  * temporary folder, is what the lock says it came from). An extension with the same name in that
128
- * scope is replaced.
147
+ * scope is replaced; its data folder stays. With `link`, a folder is registered where it is
148
+ * instead, to develop it: not copied, and its files not checked.
129
149
  */
130
150
  export declare function addExtension(source: string, scopeDir: string, options?: {
131
151
  subdir?: string;
132
152
  sha?: string;
133
153
  marketplace?: string;
134
154
  origin?: string;
155
+ link?: boolean;
135
156
  }): Promise<{
136
157
  name: string;
137
158
  replaced: boolean;
138
159
  commit?: string;
139
160
  }>;
140
- /** Removes an extension from a scope (its files and its lock entry); false when it wasn't there. */
141
- export declare function removeExtension(scopeDir: string, name: string): Promise<boolean>;
161
+ /**
162
+ * Removes an extension from a scope (its files and its lock entry; a linked one's folder is left
163
+ * where it is), and its data folder with `deleteData`; false when it wasn't there.
164
+ */
165
+ export declare function removeExtension(scopeDir: string, name: string, options?: {
166
+ deleteData?: boolean;
167
+ }): Promise<boolean>;
168
+ /** The size in bytes of the files under `dir` (0 when it doesn't exist). */
169
+ export declare function folderSize(dir: string): Promise<number>;
142
170
  /** Enables or disables an installed extension in a scope; false when it isn't there. */
143
171
  export declare function setExtensionEnabled(scopeDir: string, name: string, enabled: boolean): Promise<boolean>;
@@ -10,6 +10,15 @@ import { truncate } from "./toolLabels.js";
10
10
  import { agentKitVersion } from "./version.js";
11
11
  import { outsideArchive } from "./packaged.js";
12
12
  export const LOCK_FILE = "extensions.lock.json";
13
+ /**
14
+ * An installed extension's data folder: `${CLAUDE_PLUGIN_DATA}` in its servers' arguments and
15
+ * variables (a browser profile, a cache), in its scope, outside its plugin and its hash, so it's
16
+ * kept across sessions and updates. Created when a session starts it; removing the extension
17
+ * asks before deleting it.
18
+ */
19
+ export function extensionDataDir(scopeDir, name) {
20
+ return path.join(scopeDir, ".data", name);
21
+ }
13
22
  /** A scope's lock: its extensions by name. */
14
23
  export async function readLock(scopeDir) {
15
24
  try {
@@ -99,29 +108,32 @@ async function pluginServers(dir, raw) {
99
108
  }
100
109
  return servers;
101
110
  }
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] ?? "")));
111
+ /** `${CLAUDE_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_DATA}` and `${VAR}` (the agent's variable) in a server's values. */
112
+ function expand(value, folders) {
113
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => name === "CLAUDE_PLUGIN_ROOT" ? folders.root : name === "CLAUDE_PLUGIN_DATA" ? folders.data : (process.env[name] ?? ""));
105
114
  }
106
115
  /**
107
116
  * Why one of an extension's servers can't run through the launcher (only Node, its script
108
117
  * first, inside the extension), or how the kit starts it.
109
118
  */
110
- function serverLaunch(name, server, root) {
119
+ function serverLaunch(name, server, folders) {
120
+ const { root } = folders;
111
121
  if (!/^node(\.exe)?$/i.test(path.basename(server.command)))
112
122
  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));
123
+ const [script, ...args] = (server.args ?? []).map((arg) => expand(arg, folders));
114
124
  if (!script)
115
125
  return `gives its server "${name}" no script to run`;
116
126
  const entry = path.resolve(root, script);
117
127
  if (path.relative(root, entry).startsWith(".."))
118
128
  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)])) };
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 } };
120
132
  }
121
133
  /** Why an extension's servers can't run, or nothing: each must be Node, with its script there. */
122
134
  async function serversProblem(manifest, root) {
123
135
  for (const [name, server] of Object.entries(manifest.servers)) {
124
- const launch = serverLaunch(name, server, root);
136
+ const launch = serverLaunch(name, server, { root, data: "" });
125
137
  if (typeof launch === "string")
126
138
  return launch;
127
139
  if (!(await stat(launch.entry).catch(() => null))?.isFile())
@@ -188,9 +200,9 @@ export async function listInstalled(dirs) {
188
200
  if (!scopeDir)
189
201
  continue;
190
202
  for (const [name, lock] of Object.entries(await readLock(scopeDir))) {
191
- const dir = path.join(scopeDir, name);
203
+ const dir = lock.linked ? lock.source : path.join(scopeDir, name);
192
204
  const manifest = await readExternalManifest(dir).catch(() => undefined);
193
- installed.push({ name, scope, dir, lock, shadowed: installed.some((other) => other.name === name), ...(manifest ? { manifest } : {}) });
205
+ installed.push({ name, scope, scopeDir, dir, dataDir: extensionDataDir(scopeDir, name), lock, shadowed: installed.some((other) => other.name === name), ...(manifest ? { manifest } : {}) });
194
206
  }
195
207
  }
196
208
  return installed;
@@ -218,10 +230,13 @@ function externalExtension(installed, manifest) {
218
230
  name: manifest.name,
219
231
  plugin: installed.dir,
220
232
  external: true,
233
+ installedIn: installed.scopeDir,
221
234
  async contribute() {
222
235
  const servers = {};
236
+ if (Object.keys(manifest.servers).length)
237
+ await mkdir(installed.dataDir, { recursive: true });
223
238
  for (const [name, server] of Object.entries(manifest.servers)) {
224
- const launch = serverLaunch(name, server, installed.dir);
239
+ const launch = serverLaunch(name, server, { root: installed.dir, data: installed.dataDir });
225
240
  if (typeof launch === "string")
226
241
  continue; // checked when it loaded
227
242
  // Its `env` reaches it (the SDK merges it with the agent's); the launcher removes the rest.
@@ -271,7 +286,7 @@ export async function onPath(program, env = process.env) {
271
286
  async function problem(installed) {
272
287
  let manifest;
273
288
  try {
274
- if ((await hashExtension(installed.dir)) !== installed.lock.sha256)
289
+ if (!installed.lock.linked && (await hashExtension(installed.dir)) !== installed.lock.sha256)
275
290
  return "has files that changed since it was installed: install it again to trust them";
276
291
  manifest = await readExternalManifest(installed.dir);
277
292
  }
@@ -300,9 +315,12 @@ export function isGitSource(source) {
300
315
  * a commit, and `subdir` for one inside it): copies its files (never runs a script), checks its
301
316
  * manifest, and locks it, enabled, with the marketplace it came from when it did (`origin`, for a
302
317
  * temporary folder, is what the lock says it came from). An extension with the same name in that
303
- * scope is replaced.
318
+ * 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.
304
320
  */
305
321
  export async function addExtension(source, scopeDir, options = {}) {
322
+ if (options.link && isGitSource(source))
323
+ throw new Error("Only a folder can be linked: a git repository is installed as a copy.");
306
324
  let from = path.resolve(source);
307
325
  let commit;
308
326
  let clone;
@@ -326,10 +344,18 @@ export async function addExtension(source, scopeDir, options = {}) {
326
344
  if (servers)
327
345
  throw new Error(`${manifest.name} can't be installed: it ${servers}.`);
328
346
  const target = path.join(scopeDir, manifest.name);
347
+ // Replacing what's installed there would delete the source itself.
348
+ if (path.resolve(from) === path.resolve(target))
349
+ throw new Error(`${from} is where ${manifest.name} is installed: install it from another folder.`);
329
350
  const lock = await readLock(scopeDir);
330
351
  const replaced = Boolean(lock[manifest.name]);
331
352
  await rm(target, { recursive: true, force: true });
332
353
  await mkdir(scopeDir, { recursive: true });
354
+ if (options.link) {
355
+ lock[manifest.name] = { source: from, linked: true, sha256: "", enabled: true, installed: new Date().toISOString() };
356
+ await writeLock(scopeDir, lock);
357
+ return { name: manifest.name, replaced };
358
+ }
333
359
  await cp(from, target, { recursive: true, filter: (file) => path.basename(file) !== ".git" });
334
360
  lock[manifest.name] = {
335
361
  source: options.origin ?? (isGitSource(source) ? `${source}${options.subdir ? ` (${options.subdir})` : ""}` : from),
@@ -347,16 +373,33 @@ export async function addExtension(source, scopeDir, options = {}) {
347
373
  await rm(clone, { recursive: true, force: true });
348
374
  }
349
375
  }
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) {
376
+ /**
377
+ * Removes an extension from a scope (its files and its lock entry; a linked one's folder is left
378
+ * where it is), and its data folder with `deleteData`; false when it wasn't there.
379
+ */
380
+ export async function removeExtension(scopeDir, name, options = {}) {
352
381
  const lock = await readLock(scopeDir);
353
382
  if (!lock[name])
354
383
  return false;
355
384
  delete lock[name];
356
385
  await rm(path.join(scopeDir, name), { recursive: true, force: true });
386
+ if (options.deleteData)
387
+ await rm(extensionDataDir(scopeDir, name), { recursive: true, force: true });
357
388
  await writeLock(scopeDir, lock);
358
389
  return true;
359
390
  }
391
+ /** The size in bytes of the files under `dir` (0 when it doesn't exist). */
392
+ export async function folderSize(dir) {
393
+ let size = 0;
394
+ for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
395
+ const full = path.join(dir, entry.name);
396
+ if (entry.isDirectory())
397
+ size += await folderSize(full);
398
+ else if (entry.isFile())
399
+ size += (await stat(full)).size;
400
+ }
401
+ return size;
402
+ }
360
403
  /** Enables or disables an installed extension in a scope; false when it isn't there. */
361
404
  export async function setExtensionEnabled(scopeDir, name, enabled) {
362
405
  const lock = await readLock(scopeDir);
@@ -5,23 +5,37 @@ import type { ToolLabels } from "./toolLabels.js";
5
5
  * Turning an installed extension off and on in a running session (#49), without reopening it.
6
6
  * Only one that was running when the session opened: its MCP servers are toggled
7
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
8
+ * confirmed empirically) and its plugin, mounted in the run's folder, is unmounted or mounted again
9
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'
10
+ * empirically, through a link too). The kit's own lists follow: the plan gate's read-only tools, the subagent gates'
11
11
  * allowed types, the chat's labels, `/extensions` and what `about_me` says. One that wasn't
12
12
  * running has no plugin path the session knows: it takes reopening the session.
13
13
  *
14
14
  * Not through `setMcpServers()`: it leaves the servers given at start alone (confirmed
15
15
  * empirically: removing one from its set removes nothing).
16
16
  */
17
+ /**
18
+ * Mounts an installed extension's plugin at `at`, in a run's folder, for the session to load from
19
+ * there: a link to it (a junction on Windows, which needs no privilege), so nothing is copied and
20
+ * its `node_modules` costs nothing; a copy where a link can't be made. The CLI loads a plugin
21
+ * through a link, and `reloadPlugins()` unloads it when the link goes and loads it when it's back
22
+ * (confirmed empirically).
23
+ */
24
+ export declare function mountPlugin(from: string, at: string): Promise<"link" | "copy">;
25
+ /**
26
+ * Takes a mounted plugin away: a link is removed, never what it points at (its contents are the
27
+ * installed extension); a copy is emptied, so the path stays for mounting it again.
28
+ */
29
+ export declare function unmountPlugin(at: string): Promise<void>;
17
30
  /** What an installed extension brought to this session, to take it away and bring it back. */
18
31
  export interface LiveExtension {
19
32
  name: string;
20
- /** Where it's installed (its scope's folder): the plugin copy is filled from there. */
33
+ /** Its plugin as installed (in its scope's folder, or where it's linked): what's mounted in the run's folder. */
21
34
  installedDir: string;
35
+ /** Its scope's folder, where its lock is. */
22
36
  scopeDir: string;
23
- /** Its plugin, copied into the run's folder for this session. */
24
- pluginCopy: string;
37
+ /** Where its plugin is mounted in the run's folder for this session (`mountPlugin()`). */
38
+ pluginMount: string;
25
39
  servers: string[];
26
40
  readOnlyTools: string[];
27
41
  toolLabels: ToolLabels;
@@ -1,6 +1,57 @@
1
- import { cp, readdir, rm } from "node:fs/promises";
1
+ import { cp, lstat, mkdir, readdir, rm, symlink, unlink } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { hashExtension, listInstalled, readLock } from "./externalExtensions.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, mounted in the run's folder, is unmounted or mounted again
9
+ * before `reloadPlugins()` (its skills, commands and subagents go and come back, confirmed
10
+ * empirically, through a link too). 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
+ /**
18
+ * Mounts an installed extension's plugin at `at`, in a run's folder, for the session to load from
19
+ * there: a link to it (a junction on Windows, which needs no privilege), so nothing is copied and
20
+ * its `node_modules` costs nothing; a copy where a link can't be made. The CLI loads a plugin
21
+ * through a link, and `reloadPlugins()` unloads it when the link goes and loads it when it's back
22
+ * (confirmed empirically).
23
+ */
24
+ export async function mountPlugin(from, at) {
25
+ // Whatever was there goes: a link (never what it points at), or a copy, whole.
26
+ const found = await lstat(at).catch(() => undefined);
27
+ if (found?.isSymbolicLink())
28
+ await unlink(at);
29
+ else if (found)
30
+ await rm(at, { recursive: true, force: true });
31
+ await mkdir(path.dirname(at), { recursive: true });
32
+ try {
33
+ await symlink(from, at, process.platform === "win32" ? "junction" : "dir");
34
+ return "link";
35
+ }
36
+ catch {
37
+ await cp(from, at, { recursive: true });
38
+ return "copy";
39
+ }
40
+ }
41
+ /**
42
+ * Takes a mounted plugin away: a link is removed, never what it points at (its contents are the
43
+ * installed extension); a copy is emptied, so the path stays for mounting it again.
44
+ */
45
+ export async function unmountPlugin(at) {
46
+ const found = await lstat(at).catch(() => undefined);
47
+ if (!found)
48
+ return;
49
+ if (found.isSymbolicLink())
50
+ await unlink(at);
51
+ else
52
+ for (const entry of await readdir(at))
53
+ await rm(path.join(at, entry), { recursive: true, force: true });
54
+ }
4
55
  /** The switch for a session's installed extensions. */
5
56
  export function createExtensionSwitch(extensions, targets) {
6
57
  const off = new Set();
@@ -13,14 +64,14 @@ export function createExtensionSwitch(extensions, targets) {
13
64
  return true; // already as asked
14
65
  if (enabled) {
15
66
  // Its files as they were installed, or nothing: a changed one is for a new session to judge.
67
+ // A linked one's aren't checked: it's being developed, and a rebuild is why it's turned on again.
16
68
  const lock = (await readLock(extension.scopeDir))[name];
17
- if (!lock || lock.sha256 !== (await hashExtension(extension.installedDir)))
69
+ if (!lock || (!lock.linked && lock.sha256 !== (await hashExtension(extension.installedDir))))
18
70
  return false;
19
- await cp(extension.installedDir, extension.pluginCopy, { recursive: true });
71
+ await mountPlugin(extension.installedDir, extension.pluginMount);
20
72
  }
21
73
  else {
22
- for (const entry of await readdir(extension.pluginCopy))
23
- await rm(path.join(extension.pluginCopy, entry), { recursive: true, force: true });
74
+ await unmountPlugin(extension.pluginMount);
24
75
  }
25
76
  await controls.reloadPlugins();
26
77
  for (const server of extension.servers)
@@ -1,4 +1,4 @@
1
- import { cp, readdir, readFile, rm } from "node:fs/promises";
1
+ import { readdir, readFile } 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,7 +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
+ import { createExtensionSwitch, mountPlugin } from "./liveExtensions.js";
26
26
  import { agentKitVersion } from "./version.js";
27
27
  /**
28
28
  * Builds the `options` object passed to the Agent SDK's `query()` — everything about
@@ -81,15 +81,15 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
81
81
  const enabledNames = new Set((spec.extensions ?? []).map((extension) => extension.name));
82
82
  const resolved = resolveExtensions([...(spec.extensions ?? []), ...external.extensions.filter((extension) => !enabledNames.has(extension.name))], extensionContext);
83
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.
84
+ // Each installed one's plugin, mounted in the run's folder (a link, nothing copied): the running
85
+ // session unmounts and mounts it to turn the extension off and on (liveExtensions.ts), never
86
+ // touching what's installed.
86
87
  const installedDirs = new Map();
87
88
  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;
89
+ const mount = path.join(runDir, "extensions", extension.name);
90
+ await mountPlugin(extension.plugin, mount);
91
+ installedDirs.set(extension.name, { dir: extension.plugin, scopeDir: extension.installedIn ?? path.dirname(extension.plugin) });
92
+ extension.plugin = mount;
93
93
  }
94
94
  const externalPlugins = new Set(resolved.active.filter(({ extension }) => extension.external).map(({ extension }) => path.resolve(extension.plugin)));
95
95
  extensionContext.capabilities = resolved.capabilities;
@@ -359,16 +359,16 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
359
359
  registerSessionView(sdkOptions, late.view);
360
360
  // Turning an installed extension off and on while the session runs (#49).
361
361
  const switchExtension = createExtensionSwitch(resolved.active.flatMap(({ extension, manifest }, index) => {
362
- const installedDir = installedDirs.get(extension.name);
363
- if (!installedDir)
362
+ const installed = installedDirs.get(extension.name);
363
+ if (!installed)
364
364
  return [];
365
365
  const contribution = added[index];
366
366
  return [
367
367
  {
368
368
  name: extension.name,
369
- installedDir,
370
- scopeDir: path.dirname(installedDir),
371
- pluginCopy: extension.plugin,
369
+ installedDir: installed.dir,
370
+ scopeDir: installed.scopeDir,
371
+ pluginMount: extension.plugin,
372
372
  servers: Object.keys(contribution?.mcpServers ?? {}),
373
373
  readOnlyTools: contribution?.readOnlyTools ?? [],
374
374
  toolLabels: contribution?.toolLabels ?? {},
package/dist/index.d.ts CHANGED
@@ -16,7 +16,7 @@ export { sources, type SourcesOptions } from "./extensions/sources/index.js";
16
16
  export { knowledge, type KnowledgeOptions } from "./extensions/knowledge/index.js";
17
17
  export { memory, type MemoryOptions } from "./extensions/memory/index.js";
18
18
  export { readExtensionManifest, type Extension, type ExtensionContext, type ExtensionContribution, type ExtensionManifest, type FolderOption, } from "./core/extensions.js";
19
- export { addExtension, removeExtension, setExtensionEnabled, listInstalled, readExternalManifest, type ExtensionAuthor, type ExtensionDirs, type ExtensionScope, type ExternalManifest, type ExternalToolLabel, type PluginServer, type InstalledExtension, type LockEntry, } from "./core/externalExtensions.js";
19
+ export { addExtension, extensionDataDir, removeExtension, setExtensionEnabled, listInstalled, readExternalManifest, type ExtensionAuthor, type ExtensionDirs, type ExtensionScope, type ExternalManifest, type ExternalToolLabel, type PluginServer, type InstalledExtension, type LockEntry, } from "./core/externalExtensions.js";
20
20
  export { addMarketplace, findPlugin, inspectMarketplace, installFromMarketplace, listMarketplaces, readMarketplaceManifest, removeMarketplace, resolvePluginSource, updateMarketplace, type KnownMarketplace, type MarketplaceManifest, type MarketplacePlugin, type PluginSource, } from "./core/marketplaces.js";
21
21
  export { runExtensionCommand } from "./tui/extensionCommand.js";
22
22
  export { agentKitVersion } from "./core/version.js";
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ export { sources } from "./extensions/sources/index.js";
16
16
  export { knowledge } from "./extensions/knowledge/index.js";
17
17
  export { memory } from "./extensions/memory/index.js";
18
18
  export { readExtensionManifest, } from "./core/extensions.js";
19
- export { addExtension, removeExtension, setExtensionEnabled, listInstalled, readExternalManifest, } from "./core/externalExtensions.js";
19
+ export { addExtension, extensionDataDir, removeExtension, setExtensionEnabled, listInstalled, readExternalManifest, } from "./core/externalExtensions.js";
20
20
  export { addMarketplace, findPlugin, inspectMarketplace, installFromMarketplace, listMarketplaces, readMarketplaceManifest, removeMarketplace, resolvePluginSource, updateMarketplace, } from "./core/marketplaces.js";
21
21
  export { runExtensionCommand } from "./tui/extensionCommand.js";
22
22
  export { agentKitVersion } from "./core/version.js";
@@ -9,7 +9,9 @@ export { chatExtensionsCommand, describe } from "../chat/extensions.js";
9
9
  * - `extension list`
10
10
  * - `extension info <name>`: its metadata, what it offers and where its README is
11
11
  * - `extension add <folder | git URL[#ref] | plugin[@marketplace]> [--project] [--path <subfolder>] [--yes]`
12
- * - `extension remove|enable|disable <name> [--project | --agent]`
12
+ * - `extension add <folder> --link [--project]`: registered where it is, to develop it (not copied, its files not checked)
13
+ * - `extension remove <name> [--project | --agent] [--yes | --keep-data]`: asks before deleting its data folder
14
+ * - `extension enable|disable <name> [--project | --agent]`
13
15
  * - `extension search [words]`: what the known marketplaces offer
14
16
  * - `extension marketplace add <folder | git URL[#ref] | owner/repo> [--yes]`, `list`, `update [name]`, `remove <name>`
15
17
  *
@@ -2,7 +2,7 @@ import { stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { addMarketplace, findPlugin, inspectMarketplace, installFromMarketplace, listMarketplaces, removeMarketplace, updateMarketplace, } from "../core/marketplaces.js";
5
- import { addExtension, isGitSource, listInstalled, removeExtension, setExtensionEnabled, } from "../core/externalExtensions.js";
5
+ import { addExtension, folderSize, isGitSource, listInstalled, removeExtension, setExtensionEnabled, } from "../core/externalExtensions.js";
6
6
  import { describe } from "../chat/extensions.js";
7
7
  export { chatExtensionsCommand, describe } from "../chat/extensions.js";
8
8
  /**
@@ -14,7 +14,9 @@ export { chatExtensionsCommand, describe } from "../chat/extensions.js";
14
14
  * - `extension list`
15
15
  * - `extension info <name>`: its metadata, what it offers and where its README is
16
16
  * - `extension add <folder | git URL[#ref] | plugin[@marketplace]> [--project] [--path <subfolder>] [--yes]`
17
- * - `extension remove|enable|disable <name> [--project | --agent]`
17
+ * - `extension add <folder> --link [--project]`: registered where it is, to develop it (not copied, its files not checked)
18
+ * - `extension remove <name> [--project | --agent] [--yes | --keep-data]`: asks before deleting its data folder
19
+ * - `extension enable|disable <name> [--project | --agent]`
18
20
  * - `extension search [words]`: what the known marketplaces offer
19
21
  * - `extension marketplace add <folder | git URL[#ref] | owner/repo> [--yes]`, `list`, `update [name]`, `remove <name>`
20
22
  *
@@ -92,6 +94,13 @@ export async function runExtensionCommand(argv, options) {
92
94
  return fail(`This agent has no ${scope} scope for extensions.`);
93
95
  // A plugin of a marketplace, unless it's a folder here or a git URL.
94
96
  const isFolder = await stat(path.resolve(source)).then((found) => found.isDirectory(), () => false);
97
+ if (flag("link")) {
98
+ if (!isFolder)
99
+ return fail(`Only a folder can be linked: ${source} isn't one here.`);
100
+ const result = await addExtension(source, dir, { link: true });
101
+ write(`Linked ${result.name} in the ${scope} scope: it runs from ${path.resolve(source)}, whose files aren't checked. A rebuild takes effect when its server starts again (a new session, or \`/extensions disable\` and \`enable\` in the chat).`);
102
+ return true;
103
+ }
95
104
  if (!isFolder && !isGitSource(source) && /^[A-Za-z0-9][\w.-]*(@[A-Za-z0-9][\w.-]*)?$/.test(source)) {
96
105
  await knownMarketplaces();
97
106
  const { marketplace, plugin } = await findPlugin(marketsDir, source);
@@ -121,13 +130,18 @@ export async function runExtensionCommand(argv, options) {
121
130
  return fail(`${name} isn't installed${askedScope ? ` in the ${askedScope} scope` : ""}. \`${command} extension list\` lists them.`);
122
131
  if (found.length > 1 && !askedScope)
123
132
  return fail(`${name} is installed in both scopes: say which, with --project or --agent.`);
124
- const { scope } = found[0];
133
+ const { scope, dataDir } = found[0];
125
134
  const dir = scopeDir(scope);
126
- if (action === "remove")
127
- await removeExtension(dir, name);
128
- else
135
+ if (action !== "remove") {
129
136
  await setExtensionEnabled(dir, name, action === "enable");
130
- write(`${name} ${action === "remove" ? "removed" : `${action}d`} in the ${scope} scope. It applies from the next session (in the chat, \`/extensions\` reopens it).`);
137
+ write(`${name} ${action}d in the ${scope} scope. It applies from the next session (in the chat, \`/extensions\` reopens it).`);
138
+ return true;
139
+ }
140
+ // Its data (a browser profile, a signed-in session) may be worth keeping for a reinstall: asked, unless said.
141
+ const size = await folderSize(dataDir);
142
+ const deleteData = size > 0 && !flag("keep-data") && (await confirmed(`Delete its data too (${dataDir}, ${formatSize(size)})? (y/N) `, (typed) => /^(y|yes|s|si|sí)$/i.test(typed)));
143
+ await removeExtension(dir, name, { deleteData });
144
+ write(`${name} removed in the ${scope} scope${size > 0 ? (deleteData ? ", with its data" : `; its data stays in ${dataDir}`) : ""}. It applies from the next session (in the chat, \`/extensions\` reopens it).`);
131
145
  return true;
132
146
  }
133
147
  case "search": {
@@ -155,7 +169,9 @@ export async function runExtensionCommand(argv, options) {
155
169
  " list what's installed, in which scope, on or off",
156
170
  " info <name> its metadata, what it offers, its README",
157
171
  " add <folder | git URL[#ref] | plugin[@marketplace]> [--project] [--path <subfolder>] [--yes]",
158
- " remove|enable|disable <name> [--project | --agent]",
172
+ " add <folder> --link [--project] to develop one: runs from its folder, not copied nor checked",
173
+ " remove <name> [--project | --agent] [--yes | --keep-data]",
174
+ " enable|disable <name> [--project | --agent]",
159
175
  " search [words] what the known marketplaces offer",
160
176
  " marketplace add <folder | git URL[#ref] | owner/repo> [--yes]",
161
177
  " marketplace list | update [name] | remove <name>",
@@ -252,6 +268,18 @@ async function info(extension) {
252
268
  ...field("Only read", manifest.readOnlyTools.join(", ") || undefined),
253
269
  ...field("Variables", Object.values(manifest.servers).flatMap((server) => Object.keys(server.env ?? {})).join(", ") || undefined),
254
270
  ...field("Installed", `${extension.scope} scope, ${extension.lock.enabled ? "enabled" : "disabled"}, on ${extension.lock.installed}, from ${describe(extension).split(" from ")[1]}`),
271
+ ...field("Data", (await stat(extension.dataDir).catch(() => null))?.isDirectory() ? `${extension.dataDir} (${formatSize(await folderSize(extension.dataDir))})` : undefined),
255
272
  ...field("README", (await stat(readme).catch(() => null))?.isFile() ? readme : undefined),
256
273
  ];
257
274
  }
275
+ /** A size in bytes, as a person reads it: `512 B`, `3.4 KB`, `120 MB`. */
276
+ function formatSize(bytes) {
277
+ const units = ["B", "KB", "MB", "GB"];
278
+ let size = bytes;
279
+ let unit = 0;
280
+ while (size >= 1024 && unit < units.length - 1) {
281
+ size /= 1024;
282
+ unit++;
283
+ }
284
+ return `${unit === 0 ? size : size.toFixed(size < 10 ? 1 : 0)} ${units[unit]}`;
285
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falkenslab/agent-kit",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Library for building an agent on top of the Claude Agent SDK",
5
5
  "author": "Francisco Vargas Ruiz",
6
6
  "license": "MIT",