@vellumai/assistant 0.10.2-dev.202606250916.8422b74 → 0.10.2-dev.202606251104.36cd100
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.
- package/package.json +1 -1
- package/src/__tests__/mtime-cache.test.ts +130 -1
- package/src/cli/commands/plugins.ts +7 -18
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +35 -0
- package/src/daemon/conversation-tool-setup.ts +25 -8
- package/src/plugins/mtime-cache.ts +126 -52
- package/src/tools/registry.ts +13 -0
package/package.json
CHANGED
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
* cache miss (changed mtime → re-import), plugin deletion (eviction),
|
|
8
8
|
* and hook collection across multiple plugins.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
utimesSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
11
18
|
import { tmpdir } from "node:os";
|
|
12
19
|
import { join } from "node:path";
|
|
13
20
|
import {
|
|
@@ -27,6 +34,11 @@ import {
|
|
|
27
34
|
populateCacheAtBoot,
|
|
28
35
|
resetPluginCacheForTests,
|
|
29
36
|
} from "../plugins/mtime-cache.js";
|
|
37
|
+
import {
|
|
38
|
+
getAllToolDefinitions,
|
|
39
|
+
getPluginToolDefinitions,
|
|
40
|
+
getToolOwner,
|
|
41
|
+
} from "../tools/registry.js";
|
|
30
42
|
|
|
31
43
|
// ─── Test fixtures ───────────────────────────────────────────────────────────
|
|
32
44
|
|
|
@@ -514,3 +526,120 @@ describe("workspace hooks (<workspace>/hooks/)", () => {
|
|
|
514
526
|
expect(rf(sentinel, "utf8")).toBe("x");
|
|
515
527
|
});
|
|
516
528
|
});
|
|
529
|
+
|
|
530
|
+
// ─── Runtime activation (hot-reload without a daemon restart) ──────────────────
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Write a hook that appends `token` to `markerPath` each time it runs, so a
|
|
534
|
+
* test can count how many times `init`/`shutdown` fired.
|
|
535
|
+
*/
|
|
536
|
+
function writeMarkerHook(
|
|
537
|
+
dir: string,
|
|
538
|
+
hookName: string,
|
|
539
|
+
markerPath: string,
|
|
540
|
+
token: string,
|
|
541
|
+
): void {
|
|
542
|
+
writeHook(
|
|
543
|
+
dir,
|
|
544
|
+
hookName,
|
|
545
|
+
`import { appendFileSync } from "node:fs";\nexport default () => { appendFileSync(${JSON.stringify(markerPath)}, ${JSON.stringify(`${token}\n`)}); };`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const TOOL_SRC = (name: string) =>
|
|
550
|
+
`export default { name: ${JSON.stringify(name)}, description: "test", parameters: { type: "object", properties: {} } };`;
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Simulate the per-turn plugin hook dispatch that drives runtime reconciliation
|
|
554
|
+
* in production: any `getUserHooksFor` call runs `scanPlugins`, which activates
|
|
555
|
+
* newly present plugins and deactivates removed ones. The hook name is
|
|
556
|
+
* irrelevant — the scan runs regardless of whether a plugin defines that hook.
|
|
557
|
+
*/
|
|
558
|
+
async function triggerScan(): Promise<void> {
|
|
559
|
+
await getUserHooksFor("user-prompt-submit");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
describe("plugin runtime activation", () => {
|
|
563
|
+
test("a plugin installed after boot becomes live on the next scan", async () => {
|
|
564
|
+
await populateCacheAtBoot(); // empty plugins dir
|
|
565
|
+
expect(getAllToolDefinitions().some((t) => t.name === "late-tool")).toBe(
|
|
566
|
+
false,
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const dir = freshPluginDir("late-plugin");
|
|
570
|
+
writePackageJson(dir, { ...SIMPLE_PKG, name: "late-plugin" });
|
|
571
|
+
writeTool(dir, "late-tool", TOOL_SRC("late-tool"));
|
|
572
|
+
const initMarker = join(ROOT, "late-init.log");
|
|
573
|
+
writeMarkerHook(dir, "init", initMarker, "init");
|
|
574
|
+
|
|
575
|
+
await triggerScan();
|
|
576
|
+
|
|
577
|
+
// Registered into the global registry as a plugin-owned tool, and exposed
|
|
578
|
+
// to the per-turn resolver via getPluginToolDefinitions().
|
|
579
|
+
expect(getToolOwner("late-tool")).toEqual({
|
|
580
|
+
kind: "plugin",
|
|
581
|
+
id: "late-plugin",
|
|
582
|
+
});
|
|
583
|
+
expect(getAllToolDefinitions().some((t) => t.name === "late-tool")).toBe(
|
|
584
|
+
true,
|
|
585
|
+
);
|
|
586
|
+
expect(getPluginToolDefinitions().some((t) => t.name === "late-tool")).toBe(
|
|
587
|
+
true,
|
|
588
|
+
);
|
|
589
|
+
// init ran exactly once.
|
|
590
|
+
expect(readFileSync(initMarker, "utf8").trim().split("\n")).toHaveLength(1);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("activation is idempotent — repeated scans do not re-run init", async () => {
|
|
594
|
+
const dir = freshPluginDir("idem-plugin");
|
|
595
|
+
writePackageJson(dir, { ...SIMPLE_PKG, name: "idem-plugin" });
|
|
596
|
+
writeTool(dir, "idem-tool", TOOL_SRC("idem-tool"));
|
|
597
|
+
const initMarker = join(ROOT, "idem-init.log");
|
|
598
|
+
writeMarkerHook(dir, "init", initMarker, "init");
|
|
599
|
+
|
|
600
|
+
await populateCacheAtBoot();
|
|
601
|
+
await triggerScan();
|
|
602
|
+
await triggerScan();
|
|
603
|
+
|
|
604
|
+
expect(readFileSync(initMarker, "utf8").trim().split("\n")).toHaveLength(1);
|
|
605
|
+
// Registered exactly once (no refcount inflation from re-registration).
|
|
606
|
+
expect(getToolOwner("idem-tool")).toEqual({
|
|
607
|
+
kind: "plugin",
|
|
608
|
+
id: "idem-plugin",
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("removing a plugin directory deactivates it (unregister + shutdown)", async () => {
|
|
613
|
+
const dir = freshPluginDir("temp-plugin");
|
|
614
|
+
writePackageJson(dir, { ...SIMPLE_PKG, name: "temp-plugin" });
|
|
615
|
+
writeTool(dir, "temp-tool", TOOL_SRC("temp-tool"));
|
|
616
|
+
const shutdownMarker = join(ROOT, "temp-shutdown.log");
|
|
617
|
+
writeMarkerHook(dir, "shutdown", shutdownMarker, "bye");
|
|
618
|
+
|
|
619
|
+
await populateCacheAtBoot();
|
|
620
|
+
expect(getToolOwner("temp-tool")?.kind).toBe("plugin");
|
|
621
|
+
|
|
622
|
+
rmSync(dir, { recursive: true, force: true });
|
|
623
|
+
await triggerScan();
|
|
624
|
+
|
|
625
|
+
expect(getToolOwner("temp-tool")).toBeUndefined();
|
|
626
|
+
expect(getPluginToolDefinitions().some((t) => t.name === "temp-tool")).toBe(
|
|
627
|
+
false,
|
|
628
|
+
);
|
|
629
|
+
expect(existsSync(shutdownMarker)).toBe(true);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("disabling a plugin at runtime tears down its tools", async () => {
|
|
633
|
+
const dir = freshPluginDir("disable-plugin");
|
|
634
|
+
writePackageJson(dir, { ...SIMPLE_PKG, name: "disable-plugin" });
|
|
635
|
+
writeTool(dir, "disable-tool", TOOL_SRC("disable-tool"));
|
|
636
|
+
|
|
637
|
+
await populateCacheAtBoot();
|
|
638
|
+
expect(getToolOwner("disable-tool")?.kind).toBe("plugin");
|
|
639
|
+
|
|
640
|
+
writeFileSync(join(dir, ".disabled"), "");
|
|
641
|
+
await triggerScan();
|
|
642
|
+
|
|
643
|
+
expect(getToolOwner("disable-tool")).toBeUndefined();
|
|
644
|
+
});
|
|
645
|
+
});
|
|
@@ -161,7 +161,6 @@ Examples:
|
|
|
161
161
|
console.log(
|
|
162
162
|
`Installed plugin "${result.name}" (${result.fileCount} file${result.fileCount === 1 ? "" : "s"})${pinned} → ${result.target}`,
|
|
163
163
|
);
|
|
164
|
-
console.log("Restart the assistant to pick up the new plugin.");
|
|
165
164
|
} catch (err) {
|
|
166
165
|
if (err instanceof PluginAlreadyInstalledError) {
|
|
167
166
|
console.error(`${err.message}\nPass --force to overwrite.`);
|
|
@@ -245,9 +244,7 @@ Examples:
|
|
|
245
244
|
|
|
246
245
|
plugins
|
|
247
246
|
.command("list")
|
|
248
|
-
.description(
|
|
249
|
-
"List plugins installed in your workspace.",
|
|
250
|
-
)
|
|
247
|
+
.description("List plugins installed in your workspace.")
|
|
251
248
|
.option("--json", "Emit machine-readable JSON instead of a table")
|
|
252
249
|
.option(
|
|
253
250
|
"--all",
|
|
@@ -276,8 +273,7 @@ Examples:
|
|
|
276
273
|
const nameW = Math.max(4, ...rows.map((r) => r.name.length));
|
|
277
274
|
const versionW = Math.max(7, ...rows.map((r) => r.version.length));
|
|
278
275
|
const sourceW = Math.max(6, ...rows.map((r) => r.source.length));
|
|
279
|
-
const pad = (s: string, w: number) =>
|
|
280
|
-
s + " ".repeat(w - s.length);
|
|
276
|
+
const pad = (s: string, w: number) => s + " ".repeat(w - s.length);
|
|
281
277
|
console.log(
|
|
282
278
|
`${pad("NAME", nameW)} ${pad("VERSION", versionW)} ${pad("SOURCE", sourceW)} STATUS`,
|
|
283
279
|
);
|
|
@@ -294,9 +290,7 @@ Examples:
|
|
|
294
290
|
console.log(
|
|
295
291
|
`${all.length} plugin${all.length === 1 ? "" : "s"} ` +
|
|
296
292
|
`(${userCount} user, ${defaultCount} default` +
|
|
297
|
-
(disabledCount > 0
|
|
298
|
-
? `, ${disabledCount} disabled`
|
|
299
|
-
: "") +
|
|
293
|
+
(disabledCount > 0 ? `, ${disabledCount} disabled` : "") +
|
|
300
294
|
`).`,
|
|
301
295
|
);
|
|
302
296
|
return;
|
|
@@ -540,7 +534,6 @@ Examples:
|
|
|
540
534
|
console.log(
|
|
541
535
|
`Uninstalled plugin "${result.name}" from ${result.target}`,
|
|
542
536
|
);
|
|
543
|
-
console.log("Restart the assistant to drop the plugin.");
|
|
544
537
|
} catch (err) {
|
|
545
538
|
if (err instanceof InvalidPluginNameError) {
|
|
546
539
|
console.error(err.message);
|
|
@@ -561,15 +554,13 @@ Examples:
|
|
|
561
554
|
plugins
|
|
562
555
|
.command("disable <name>")
|
|
563
556
|
.description(
|
|
564
|
-
"Disable a plugin by creating a .disabled sentinel file. Works for both user-installed and default plugins.
|
|
557
|
+
"Disable a plugin by creating a .disabled sentinel file. Works for both user-installed and default plugins. Takes effect immediately in a running assistant.",
|
|
565
558
|
)
|
|
566
559
|
.action((name: string) => {
|
|
567
560
|
try {
|
|
568
561
|
const result = disablePlugin(name);
|
|
569
562
|
log.info({ name: result.name }, "plugin disabled");
|
|
570
|
-
console.log(
|
|
571
|
-
`Disabled plugin "${result.name}". Restart the assistant for the change to take effect.`,
|
|
572
|
-
);
|
|
563
|
+
console.log(`Disabled plugin "${result.name}".`);
|
|
573
564
|
} catch (err) {
|
|
574
565
|
if (
|
|
575
566
|
err instanceof PluginAlreadyInStateException ||
|
|
@@ -589,15 +580,13 @@ Examples:
|
|
|
589
580
|
plugins
|
|
590
581
|
.command("enable <name>")
|
|
591
582
|
.description(
|
|
592
|
-
"Re-enable a disabled plugin by removing the .disabled sentinel file.
|
|
583
|
+
"Re-enable a disabled plugin by removing the .disabled sentinel file. Takes effect immediately.",
|
|
593
584
|
)
|
|
594
585
|
.action((name: string) => {
|
|
595
586
|
try {
|
|
596
587
|
const result = enablePlugin(name);
|
|
597
588
|
log.info({ name: result.name }, "plugin enabled");
|
|
598
|
-
console.log(
|
|
599
|
-
`Enabled plugin "${result.name}". Restart the assistant for the change to take effect.`,
|
|
600
|
-
);
|
|
589
|
+
console.log(`Enabled plugin "${result.name}".`);
|
|
601
590
|
} catch (err) {
|
|
602
591
|
if (
|
|
603
592
|
err instanceof PluginAlreadyInStateException ||
|
|
@@ -12,6 +12,7 @@ import type { ToolDefinition } from "../../providers/types.js";
|
|
|
12
12
|
import {
|
|
13
13
|
__clearRegistryForTesting,
|
|
14
14
|
registerMcpTools,
|
|
15
|
+
registerPluginTools,
|
|
15
16
|
} from "../../tools/registry.js";
|
|
16
17
|
import type { Tool } from "../../tools/types.js";
|
|
17
18
|
import { createResolveToolsCallback } from "../conversation-tool-setup.js";
|
|
@@ -33,6 +34,14 @@ function mcpTool(name: string): Tool {
|
|
|
33
34
|
} as unknown as Tool;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
function pluginTool(name: string): Tool {
|
|
38
|
+
return {
|
|
39
|
+
name,
|
|
40
|
+
description: name,
|
|
41
|
+
input_schema: def(name).input_schema,
|
|
42
|
+
} as unknown as Tool;
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
function makeCtx(
|
|
37
46
|
overrides: Partial<SkillProjectionContext> = {},
|
|
38
47
|
): SkillProjectionContext {
|
|
@@ -139,6 +148,32 @@ describe("createResolveToolsCallback — config.tools.exclude", () => {
|
|
|
139
148
|
expect(names).toEqual(["recall", "file_read"]);
|
|
140
149
|
});
|
|
141
150
|
|
|
151
|
+
test("plugin tool registered after resolver creation is picked up next turn", () => {
|
|
152
|
+
getConfigSpy = withExclude([]);
|
|
153
|
+
// Resolver created when only a core tool exists — no plugin yet, mirroring
|
|
154
|
+
// a conversation that started before the plugin was installed.
|
|
155
|
+
const resolver = createResolveToolsCallback([def("file_read")], makeCtx());
|
|
156
|
+
expect(resolver!([]).map((d) => d.name)).toEqual(["file_read"]);
|
|
157
|
+
|
|
158
|
+
// A plugin installed + activated mid-conversation lands its tool in the
|
|
159
|
+
// registry. The resolver must surface it on the next turn without the
|
|
160
|
+
// conversation being recreated (the plugin equivalent of `mcp reload`).
|
|
161
|
+
registerPluginTools("late-plugin", [pluginTool("admin_copilot_prefs")]);
|
|
162
|
+
const names = resolver!([]).map((d) => d.name);
|
|
163
|
+
expect(names).toContain("admin_copilot_prefs");
|
|
164
|
+
expect(names).toContain("file_read");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("excluded plugin tool is omitted from the resolved tool list", () => {
|
|
168
|
+
registerPluginTools("ex-plugin", [pluginTool("ex_plugin_tool")]);
|
|
169
|
+
getConfigSpy = withExclude(["ex_plugin_tool"]);
|
|
170
|
+
const resolver = createResolveToolsCallback(
|
|
171
|
+
[def("file_read"), def("ex_plugin_tool")],
|
|
172
|
+
makeCtx(),
|
|
173
|
+
);
|
|
174
|
+
expect(resolver!([]).map((d) => d.name)).toEqual(["file_read"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
142
177
|
test("excluded tool stays excluded under disk-pressure cleanup mode", () => {
|
|
143
178
|
// `bash` is a cleanup-safe tool and would normally survive cleanup mode;
|
|
144
179
|
// the exclude filter must still suppress it.
|
|
@@ -24,6 +24,7 @@ import { registerConversationSender } from "../tools/browser/browser-screencast.
|
|
|
24
24
|
import type { ToolExecutor } from "../tools/executor.js";
|
|
25
25
|
import {
|
|
26
26
|
getMcpToolDefinitions,
|
|
27
|
+
getPluginToolDefinitions,
|
|
27
28
|
getTool,
|
|
28
29
|
getWorkspaceToolDefinitions,
|
|
29
30
|
getWorkspaceToolNames,
|
|
@@ -674,14 +675,21 @@ export function createResolveToolsCallback(
|
|
|
674
675
|
): ((history: Message[]) => ToolDefinition[]) | undefined {
|
|
675
676
|
if (toolDefs.length === 0) return undefined;
|
|
676
677
|
|
|
677
|
-
// Separate the initial tool defs into core (stable) and the
|
|
678
|
-
// categories (MCP, workspace). We keep core tools from the snapshot
|
|
679
|
-
// re-read
|
|
678
|
+
// Separate the initial tool defs into core (stable) and the dynamic
|
|
679
|
+
// categories (MCP, workspace, plugin). We keep core tools from the snapshot
|
|
680
|
+
// and re-read the dynamic categories from the registry each turn. They differ
|
|
681
|
+
// downstream: plugin tools flow through the same context filter + subagent
|
|
682
|
+
// allowlist as core, while MCP and workspace tools are added raw.
|
|
680
683
|
const initialMcpDefs = getMcpToolDefinitions();
|
|
684
|
+
const initialPluginDefs = getPluginToolDefinitions();
|
|
681
685
|
const initialMcpNames = new Set(initialMcpDefs.map((d) => d.name));
|
|
682
686
|
const initialWorkspaceNames = new Set(getWorkspaceToolNames());
|
|
687
|
+
const initialPluginNames = new Set(initialPluginDefs.map((d) => d.name));
|
|
683
688
|
const coreToolDefs = toolDefs.filter(
|
|
684
|
-
(d) =>
|
|
689
|
+
(d) =>
|
|
690
|
+
!initialMcpNames.has(d.name) &&
|
|
691
|
+
!initialWorkspaceNames.has(d.name) &&
|
|
692
|
+
!initialPluginNames.has(d.name),
|
|
685
693
|
);
|
|
686
694
|
log.debug(
|
|
687
695
|
{
|
|
@@ -689,6 +697,8 @@ export function createResolveToolsCallback(
|
|
|
689
697
|
mcpCount: initialMcpDefs.length,
|
|
690
698
|
mcpTools: initialMcpDefs.map((d) => d.name),
|
|
691
699
|
workspaceCount: initialWorkspaceNames.size,
|
|
700
|
+
pluginCount: initialPluginDefs.length,
|
|
701
|
+
pluginTools: initialPluginDefs.map((d) => d.name),
|
|
692
702
|
},
|
|
693
703
|
"Conversation tool resolver initialized",
|
|
694
704
|
);
|
|
@@ -709,11 +719,18 @@ export function createResolveToolsCallback(
|
|
|
709
719
|
// serialized, so the registry settles for a subsequent turn to read.
|
|
710
720
|
void loadWorkspaceTools();
|
|
711
721
|
|
|
712
|
-
//
|
|
713
|
-
//
|
|
722
|
+
// Re-read plugin tool definitions from the registry each turn so a plugin
|
|
723
|
+
// installed/removed at runtime (activated by the per-turn scan in
|
|
724
|
+
// `plugins/mtime-cache.ts`) is picked up without recreating the
|
|
725
|
+
// conversation. Plugin tools share core's context filter + allowlist path,
|
|
726
|
+
// so combine them with the core snapshot before filtering.
|
|
727
|
+
const currentPluginDefs = getPluginToolDefinitions();
|
|
728
|
+
|
|
729
|
+
// Filter core + plugin tools based on current conversation context so that
|
|
730
|
+
// tools irrelevant to this turn (e.g. UI tools when no client is connected)
|
|
714
731
|
// are omitted from the definitions sent to the provider.
|
|
715
|
-
const filteredCoreDefs = coreToolDefs.filter(
|
|
716
|
-
isToolActiveForContext(d.name, ctx),
|
|
732
|
+
const filteredCoreDefs = [...coreToolDefs, ...currentPluginDefs].filter(
|
|
733
|
+
(d) => isToolActiveForContext(d.name, ctx),
|
|
717
734
|
);
|
|
718
735
|
|
|
719
736
|
// When the conversation is acting as a subagent, restrict core tools to
|
|
@@ -323,6 +323,7 @@ async function scanPlugins(): Promise<void> {
|
|
|
323
323
|
const manifest = await parsePluginManifest(pluginDir);
|
|
324
324
|
const pluginName = manifest?.name ?? entry;
|
|
325
325
|
if (discoveredPluginDirs.has(pluginDir)) {
|
|
326
|
+
await deactivatePlugin(pluginName);
|
|
326
327
|
await evictPlugin(pluginDir, pluginName);
|
|
327
328
|
}
|
|
328
329
|
if (!disabledPluginDirs.has(pluginDir)) {
|
|
@@ -350,9 +351,10 @@ async function scanPlugins(): Promise<void> {
|
|
|
350
351
|
await reconcilePluginTools(pluginDir, pluginName);
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
//
|
|
354
|
+
// Deactivate and evict cache entries for deleted plugins.
|
|
354
355
|
for (const [pluginDir, pluginName] of discoveredPluginDirs) {
|
|
355
356
|
if (!currentDirs.has(pluginDir)) {
|
|
357
|
+
await deactivatePlugin(pluginName);
|
|
356
358
|
await evictPlugin(pluginDir, pluginName);
|
|
357
359
|
}
|
|
358
360
|
}
|
|
@@ -366,6 +368,15 @@ async function scanPlugins(): Promise<void> {
|
|
|
366
368
|
for (const [dir, name] of sorted) {
|
|
367
369
|
discoveredPluginDirs.set(dir, name);
|
|
368
370
|
}
|
|
371
|
+
|
|
372
|
+
// Activate any plugin not yet brought up. Idempotent: already-active plugins
|
|
373
|
+
// are skipped by the `activatedNames` guard, so steady-state scans (one per
|
|
374
|
+
// hook dispatch) cost only a membership check per plugin. Tools were imported
|
|
375
|
+
// into `toolCache` by `reconcilePluginTools` above, so they are ready to
|
|
376
|
+
// register here.
|
|
377
|
+
for (const [dir, name] of discoveredPluginDirs) {
|
|
378
|
+
await activatePlugin(dir, name);
|
|
379
|
+
}
|
|
369
380
|
}
|
|
370
381
|
|
|
371
382
|
/**
|
|
@@ -410,28 +421,121 @@ async function evictAll(): Promise<void> {
|
|
|
410
421
|
disabledPluginDirs.clear();
|
|
411
422
|
}
|
|
412
423
|
|
|
413
|
-
// ───
|
|
424
|
+
// ─── Activation lifecycle ────────────────────────────────────────────────────
|
|
414
425
|
|
|
415
426
|
/**
|
|
416
|
-
* Plugins (and the workspace-hooks pseudo-owner)
|
|
417
|
-
*
|
|
418
|
-
*
|
|
427
|
+
* Plugins (and the workspace-hooks pseudo-owner) fully activated (tools
|
|
428
|
+
* registered + `init` hook run) within this process, in activation order. The
|
|
429
|
+
* process shutdown hook walks this list in reverse to tear everything down.
|
|
419
430
|
*/
|
|
420
431
|
const activatedPlugins: Array<{ name: string }> = [];
|
|
421
432
|
|
|
422
433
|
/**
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
434
|
+
* Names in {@link activatedPlugins}, kept as a set for O(1) membership and —
|
|
435
|
+
* critically — reserved *synchronously* at the top of `activatePlugin`. The
|
|
436
|
+
* per-turn hook dispatch reaches `scanPlugins` on every turn (sometimes
|
|
437
|
+
* concurrently), so the synchronous reservation is what prevents a second scan
|
|
438
|
+
* from double-activating a plugin while its async `init()` is still in flight.
|
|
439
|
+
*/
|
|
440
|
+
const activatedNames = new Set<string>();
|
|
441
|
+
|
|
442
|
+
const shutdownContext: PluginShutdownContext = {
|
|
443
|
+
assistantVersion: APP_VERSION,
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Activate a single discovered plugin: pre-import its hooks, register its tools
|
|
448
|
+
* into the global tool registry, and run its `init` hook. Idempotent — a plugin
|
|
449
|
+
* already activated (or mid-activation) is skipped. Never throws; per-surface
|
|
450
|
+
* failures are logged and the plugin still counts as activated so the shutdown
|
|
451
|
+
* hook tears down whatever came up (mirrors boot semantics).
|
|
452
|
+
*
|
|
453
|
+
* Called from `scanPlugins`, which runs both at boot and on every subsequent
|
|
454
|
+
* scan — so a plugin whose files appear at runtime (installed via the CLI or
|
|
455
|
+
* provisioned out-of-band) becomes live without a daemon restart.
|
|
456
|
+
*/
|
|
457
|
+
async function activatePlugin(
|
|
458
|
+
pluginDir: string,
|
|
459
|
+
pluginName: string,
|
|
460
|
+
): Promise<void> {
|
|
461
|
+
if (activatedNames.has(pluginName)) return;
|
|
462
|
+
// Reserve synchronously, before any await, so a re-entrant or concurrent
|
|
463
|
+
// scan observes this plugin as already handled.
|
|
464
|
+
activatedNames.add(pluginName);
|
|
465
|
+
|
|
466
|
+
// Pre-import all hooks so the first dispatch doesn't pay the import cost.
|
|
467
|
+
await preImportHooksDir(join(pluginDir, "hooks"), pluginName);
|
|
468
|
+
|
|
469
|
+
// Register this plugin's tools into the global tool registry so
|
|
470
|
+
// `getAllTools()` and `getTool()` can find them. Tools were already imported
|
|
471
|
+
// and cached by `reconcilePluginTools` during the scan.
|
|
472
|
+
const pluginTools = Array.from(toolCache.values())
|
|
473
|
+
.filter((c) => c.pluginName === pluginName)
|
|
474
|
+
.map((c) => c.tool);
|
|
475
|
+
if (pluginTools.length > 0) {
|
|
476
|
+
try {
|
|
477
|
+
registerPluginTools(pluginName, pluginTools);
|
|
478
|
+
log.info(
|
|
479
|
+
{ plugin: pluginName, count: pluginTools.length },
|
|
480
|
+
"user plugin tools registered",
|
|
481
|
+
);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
log.error(
|
|
484
|
+
{ err, plugin: pluginName },
|
|
485
|
+
`Failed to register tools for user plugin ${pluginName}`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Run the `init` hook if present.
|
|
491
|
+
await runInitHook(pluginName);
|
|
492
|
+
|
|
493
|
+
activatedPlugins.push({ name: pluginName });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Deactivate a plugin whose directory was removed or disabled at runtime:
|
|
498
|
+
* unregister its tools and run its `shutdown` hook. Must run *before*
|
|
499
|
+
* `evictPlugin` clears the hook cache, since the shutdown hook is read from it.
|
|
500
|
+
* Idempotent — a plugin that was never activated is a no-op.
|
|
501
|
+
*/
|
|
502
|
+
async function deactivatePlugin(pluginName: string): Promise<void> {
|
|
503
|
+
if (!activatedNames.has(pluginName)) return;
|
|
504
|
+
activatedNames.delete(pluginName);
|
|
505
|
+
const idx = activatedPlugins.findIndex((p) => p.name === pluginName);
|
|
506
|
+
if (idx >= 0) activatedPlugins.splice(idx, 1);
|
|
507
|
+
|
|
508
|
+
// Unregister tools before running shutdown so the model-visible surface is
|
|
509
|
+
// clean before teardown.
|
|
510
|
+
try {
|
|
511
|
+
unregisterPluginTools(pluginName);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
log.warn(
|
|
514
|
+
{ err, plugin: pluginName },
|
|
515
|
+
"user plugin tool unregister failed (continuing)",
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
await runShutdownHook(pluginName, shutdownContext, "plugin-removed");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ─── Boot population ─────────────────────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Populate the caches at boot by scanning the plugins directory once (which
|
|
526
|
+
* imports surfaces, registers tools, and runs `init` hooks via `activatePlugin`
|
|
527
|
+
* inside `scanPlugins`), activating standalone workspace hooks, and installing
|
|
528
|
+
* the process shutdown hook.
|
|
426
529
|
*
|
|
427
530
|
* This replaces the old `loadExternalPlugin` → `registerPlugin` →
|
|
428
531
|
* `bootstrapPlugins` path for user plugins. Instead of registering whole
|
|
429
|
-
* `Plugin` objects into the plugin registry, we register individual tools
|
|
430
|
-
*
|
|
431
|
-
* `shutdown` hooks are still run exactly once per boot, preserving the
|
|
432
|
-
* activation lifecycle that plugins rely on.
|
|
532
|
+
* `Plugin` objects into the plugin registry, we register individual tools into
|
|
533
|
+
* the tool registry and cache hooks by mtime.
|
|
433
534
|
*
|
|
434
|
-
* Called by `loadUserPlugins()` during daemon startup.
|
|
535
|
+
* Called by `loadUserPlugins()` during daemon startup. After boot, the same
|
|
536
|
+
* `scanPlugins` → `activatePlugin`/`deactivatePlugin` reconciliation runs on
|
|
537
|
+
* every turn via `getUserHooksFor` (plugin hook dispatch), so plugins whose
|
|
538
|
+
* files appear or disappear at runtime are picked up without a restart.
|
|
435
539
|
*/
|
|
436
540
|
export async function populateCacheAtBoot(
|
|
437
541
|
opts: { importTimeoutMs?: number } = {},
|
|
@@ -440,43 +544,9 @@ export async function populateCacheAtBoot(
|
|
|
440
544
|
setSurfaceImportTimeout(opts.importTimeoutMs);
|
|
441
545
|
}
|
|
442
546
|
|
|
547
|
+
// Scans + activates every discovered plugin (tools registered + `init` run).
|
|
443
548
|
await scanPlugins();
|
|
444
549
|
|
|
445
|
-
const shutdownContext: PluginShutdownContext = {
|
|
446
|
-
assistantVersion: APP_VERSION,
|
|
447
|
-
};
|
|
448
|
-
|
|
449
|
-
for (const [pluginDir, pluginName] of discoveredPluginDirs) {
|
|
450
|
-
// Pre-import all hooks so the first turn doesn't pay the import cost.
|
|
451
|
-
await preImportHooksDir(join(pluginDir, "hooks"), pluginName);
|
|
452
|
-
|
|
453
|
-
// Register user plugin tools into the global tool registry so
|
|
454
|
-
// `getAllTools()` and `getTool()` can find them. Tools were already
|
|
455
|
-
// imported and cached by `reconcilePluginTools` during `scanPlugins`.
|
|
456
|
-
const pluginTools = Array.from(toolCache.values())
|
|
457
|
-
.filter((c) => c.pluginName === pluginName)
|
|
458
|
-
.map((c) => c.tool);
|
|
459
|
-
if (pluginTools.length > 0) {
|
|
460
|
-
try {
|
|
461
|
-
registerPluginTools(pluginName, pluginTools);
|
|
462
|
-
log.info(
|
|
463
|
-
{ plugin: pluginName, count: pluginTools.length },
|
|
464
|
-
"user plugin tools registered",
|
|
465
|
-
);
|
|
466
|
-
} catch (err) {
|
|
467
|
-
log.error(
|
|
468
|
-
{ err, plugin: pluginName },
|
|
469
|
-
`Failed to register tools for user plugin ${pluginName}`,
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Run the `init` hook if present.
|
|
475
|
-
await runInitHook(pluginName);
|
|
476
|
-
|
|
477
|
-
activatedPlugins.push({ name: pluginName });
|
|
478
|
-
}
|
|
479
|
-
|
|
480
550
|
// Activate standalone workspace hooks under `<workspace>/hooks/`. These
|
|
481
551
|
// carry no package.json, no tools, and no install-date ordering — just hook
|
|
482
552
|
// files. Pre-import them and run their `init` hook so a workspace-wide
|
|
@@ -490,11 +560,14 @@ export async function populateCacheAtBoot(
|
|
|
490
560
|
}
|
|
491
561
|
|
|
492
562
|
// Register a single shutdown hook that walks all activated owners in reverse
|
|
493
|
-
// order, unregistering tools and running shutdown hooks.
|
|
494
|
-
|
|
563
|
+
// order, unregistering tools and running shutdown hooks. It reads the live
|
|
564
|
+
// `activatedPlugins` array at teardown time, so plugins activated after boot
|
|
565
|
+
// (runtime installs) are torn down too.
|
|
495
566
|
registerShutdownHook("user-plugins", async (reason) => {
|
|
496
|
-
for (let i =
|
|
497
|
-
const
|
|
567
|
+
for (let i = activatedPlugins.length - 1; i >= 0; i--) {
|
|
568
|
+
const entry = activatedPlugins[i];
|
|
569
|
+
if (entry === undefined) continue;
|
|
570
|
+
const { name } = entry;
|
|
498
571
|
|
|
499
572
|
// Unregister tools before running shutdown so onShutdown sees a
|
|
500
573
|
// clean model-visible surface. (No-op for the workspace-hooks owner,
|
|
@@ -533,6 +606,7 @@ export function resetPluginCacheForTests(): void {
|
|
|
533
606
|
discoveredPluginDirs.clear();
|
|
534
607
|
installDateCache.clear();
|
|
535
608
|
activatedPlugins.length = 0;
|
|
609
|
+
activatedNames.clear();
|
|
536
610
|
disabledPluginDirs.clear();
|
|
537
611
|
}
|
|
538
612
|
|
package/src/tools/registry.ts
CHANGED
|
@@ -548,6 +548,19 @@ export function getMcpToolDefinitions(): Tool[] {
|
|
|
548
548
|
);
|
|
549
549
|
}
|
|
550
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Return tool definitions for all currently registered plugin-origin tools.
|
|
553
|
+
* Used by the session resolver to dynamically pick up plugin tools that were
|
|
554
|
+
* registered after session creation — e.g. a plugin installed at runtime and
|
|
555
|
+
* activated on a subsequent turn (see `plugins/mtime-cache.ts`). Mirrors
|
|
556
|
+
* {@link getMcpToolDefinitions} so a plugin install behaves like `mcp reload`.
|
|
557
|
+
*/
|
|
558
|
+
export function getPluginToolDefinitions(): Tool[] {
|
|
559
|
+
return Array.from(tools.values()).filter(
|
|
560
|
+
(t) => ownersByName.get(t.name)?.kind === "plugin",
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
551
564
|
/**
|
|
552
565
|
* Return MCP tools grouped by their owning server ID. Each entry contains
|
|
553
566
|
* the server ID and the tool definitions registered by that server.
|