@vellumai/assistant 0.10.2-dev.202606250916.8422b74 → 0.10.2-dev.202606251257.2eba8a4
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/__tests__/plugin-bootstrap.test.ts +5 -5
- package/src/__tests__/plugin-route-contribution.test.ts +2 -2
- package/src/__tests__/plugin-tool-contribution.test.ts +2 -2
- package/src/__tests__/plugin-types.test.ts +2 -2
- 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/daemon/external-plugins-bootstrap.ts +5 -5
- package/src/hooks/hook-loader.ts +13 -13
- package/src/plugin-api/index.ts +6 -6
- package/src/plugin-api/types.ts +9 -9
- package/src/plugins/defaults/advisor/hooks/post-model-call.ts +2 -2
- package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +2 -2
- package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +2 -2
- package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +2 -2
- package/src/plugins/defaults/empty-response/hooks/stop.ts +2 -2
- package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +2 -2
- package/src/plugins/defaults/history-repair/hooks/post-model-call.ts +2 -2
- package/src/plugins/defaults/history-repair/hooks/stop.ts +2 -2
- package/src/plugins/defaults/history-repair/hooks/user-prompt-submit.ts +2 -2
- package/src/plugins/defaults/image-fallback/hooks/post-tool-use.ts +2 -2
- package/src/plugins/defaults/image-fallback/hooks/user-prompt-submit.ts +2 -2
- package/src/plugins/defaults/image-recovery/hooks/post-model-call.ts +2 -2
- package/src/plugins/defaults/image-recovery/hooks/stop.ts +2 -2
- package/src/plugins/defaults/max-tokens-continue/hooks/post-model-call.ts +2 -2
- package/src/plugins/defaults/max-tokens-continue/hooks/stop.ts +2 -2
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +2 -2
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +2 -2
- package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +2 -2
- package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +2 -2
- package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +2 -2
- package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +2 -2
- package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +2 -2
- package/src/plugins/defaults/title-generate/hooks/stop.ts +2 -2
- package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +2 -2
- package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +2 -2
- package/src/plugins/defaults/tool-result-truncate/hooks/post-tool-use.ts +2 -2
- package/src/plugins/external-plugin-loader.ts +2 -2
- package/src/plugins/mtime-cache.ts +130 -59
- package/src/plugins/pipeline.ts +2 -2
- package/src/plugins/registry.ts +5 -5
- package/src/plugins/types.ts +7 -7
- package/src/tools/registry.ts +13 -0
- package/src/tools/types.ts +114 -23
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
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tests for plugin bootstrap (PR 14).
|
|
3
3
|
*
|
|
4
4
|
* Covers:
|
|
5
|
-
* - A noop `init()` fires with a valid `
|
|
5
|
+
* - A noop `init()` fires with a valid `InitContext` that exposes every
|
|
6
6
|
* documented field.
|
|
7
7
|
* - Version-mismatch registration fails with an error that names the plugin
|
|
8
8
|
* (the registry enforces this at `registerPlugin` time, so bootstrap never
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
registerPlugin,
|
|
30
30
|
resetPluginRegistryForTests,
|
|
31
31
|
} from "../plugins/registry.js";
|
|
32
|
-
import { type
|
|
32
|
+
import { type InitContext, type Plugin } from "../plugins/types.js";
|
|
33
33
|
import { APP_VERSION } from "../version.js";
|
|
34
34
|
import { setOverridesForTesting } from "./feature-flag-test-helpers.js";
|
|
35
35
|
|
|
@@ -51,7 +51,7 @@ function buildPlugin(
|
|
|
51
51
|
name: string,
|
|
52
52
|
extras: Partial<Omit<Plugin, "manifest" | "hooks">> & {
|
|
53
53
|
hooks?: Plugin["hooks"];
|
|
54
|
-
init?: (ctx:
|
|
54
|
+
init?: (ctx: InitContext) => Promise<void>;
|
|
55
55
|
onShutdown?: () => Promise<void>;
|
|
56
56
|
} = {},
|
|
57
57
|
options: {
|
|
@@ -98,8 +98,8 @@ describe("plugin bootstrap", () => {
|
|
|
98
98
|
await rm(TEST_WORKSPACE_DIR, { recursive: true, force: true });
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
test("noop plugin: init fires with a fully-populated
|
|
102
|
-
let received:
|
|
101
|
+
test("noop plugin: init fires with a fully-populated InitContext", async () => {
|
|
102
|
+
let received: InitContext | undefined;
|
|
103
103
|
const plugin: Plugin = buildPlugin("alpha", {
|
|
104
104
|
async init(ctx) {
|
|
105
105
|
received = ctx;
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
registerPlugin,
|
|
39
39
|
resetPluginRegistryForTests,
|
|
40
40
|
} from "../plugins/registry.js";
|
|
41
|
-
import type {
|
|
41
|
+
import type { InitContext, Plugin } from "../plugins/types.js";
|
|
42
42
|
import {
|
|
43
43
|
matchSkillRoute,
|
|
44
44
|
resetSkillRoutesForTests,
|
|
@@ -64,7 +64,7 @@ function buildPlugin(
|
|
|
64
64
|
name: string,
|
|
65
65
|
extras: Partial<Omit<Plugin, "manifest" | "hooks">> & {
|
|
66
66
|
hooks?: Plugin["hooks"];
|
|
67
|
-
init?: (ctx:
|
|
67
|
+
init?: (ctx: InitContext) => Promise<void>;
|
|
68
68
|
onShutdown?: () => Promise<void>;
|
|
69
69
|
} = {},
|
|
70
70
|
): Plugin {
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
registerPlugin,
|
|
34
34
|
resetPluginRegistryForTests,
|
|
35
35
|
} from "../plugins/registry.js";
|
|
36
|
-
import type {
|
|
36
|
+
import type { InitContext, Plugin } from "../plugins/types.js";
|
|
37
37
|
import {
|
|
38
38
|
__clearRegistryForTesting,
|
|
39
39
|
__resetRegistryForTesting,
|
|
@@ -82,7 +82,7 @@ function buildPlugin(
|
|
|
82
82
|
name: string,
|
|
83
83
|
extras: Partial<Omit<Plugin, "manifest" | "hooks">> & {
|
|
84
84
|
hooks?: Plugin["hooks"];
|
|
85
|
-
init?: (ctx:
|
|
85
|
+
init?: (ctx: InitContext) => Promise<void>;
|
|
86
86
|
onShutdown?: () => Promise<void>;
|
|
87
87
|
} = {},
|
|
88
88
|
): Plugin {
|
|
@@ -13,9 +13,9 @@ import { describe, expect, test } from "bun:test";
|
|
|
13
13
|
import type { TrustContext } from "../daemon/trust-context.js";
|
|
14
14
|
import { RiskLevel } from "../permissions/types.js";
|
|
15
15
|
import {
|
|
16
|
+
type InitContext,
|
|
16
17
|
type Plugin,
|
|
17
18
|
PluginExecutionError,
|
|
18
|
-
type PluginInitContext,
|
|
19
19
|
type PluginManifest,
|
|
20
20
|
type TurnContext,
|
|
21
21
|
} from "../plugins/types.js";
|
|
@@ -57,7 +57,7 @@ describe("plugin core types", () => {
|
|
|
57
57
|
const plugin = {
|
|
58
58
|
manifest,
|
|
59
59
|
hooks: {
|
|
60
|
-
async init(ctx:
|
|
60
|
+
async init(ctx: InitContext) {
|
|
61
61
|
// Touch every field so refactors that rename any of them break here.
|
|
62
62
|
void ctx.config;
|
|
63
63
|
void ctx.logger;
|
|
@@ -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
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* (Zod schemas with `.parse()` are supported; anything else is passed
|
|
25
25
|
* through untouched).
|
|
26
26
|
* 5. Creates `<workspaceDir>/plugins-data/<plugin>/` on demand for per-plugin
|
|
27
|
-
* writable state and exposes it via {@link
|
|
27
|
+
* writable state and exposes it via {@link InitContext.pluginStorageDir}.
|
|
28
28
|
* 6. For each surviving plugin, registers its contributed tools and routes
|
|
29
29
|
* into their global registries via {@link registerPluginTools} and
|
|
30
30
|
* {@link registerSkillRoute}. Contributions land BEFORE `init()` so
|
|
@@ -64,7 +64,7 @@ import { getRegisteredPlugins, unregisterPlugin } from "../plugins/registry.js";
|
|
|
64
64
|
import {
|
|
65
65
|
type Plugin,
|
|
66
66
|
PluginExecutionError,
|
|
67
|
-
type
|
|
67
|
+
type ShutdownContext,
|
|
68
68
|
} from "../plugins/types.js";
|
|
69
69
|
import { loadUserPlugins } from "../plugins/user-loader.js";
|
|
70
70
|
import {
|
|
@@ -222,8 +222,8 @@ export async function bootstrapPlugins(): Promise<void> {
|
|
|
222
222
|
// Shutdown context is identical for every plugin in this boot — construct
|
|
223
223
|
// once and reuse across the per-plugin teardown and the normal shutdown
|
|
224
224
|
// hook below. Only `assistantVersion` is exposed today; future additions
|
|
225
|
-
// live on {@link
|
|
226
|
-
const shutdownContext:
|
|
225
|
+
// live on {@link ShutdownContext}.
|
|
226
|
+
const shutdownContext: ShutdownContext = {
|
|
227
227
|
assistantVersion: APP_VERSION,
|
|
228
228
|
};
|
|
229
229
|
|
|
@@ -407,7 +407,7 @@ async function initializePlugin(
|
|
|
407
407
|
async function teardownPlugin(
|
|
408
408
|
active: ActivePlugin,
|
|
409
409
|
reason: string,
|
|
410
|
-
shutdownContext:
|
|
410
|
+
shutdownContext: ShutdownContext,
|
|
411
411
|
): Promise<void> {
|
|
412
412
|
const { plugin, routeHandles } = active;
|
|
413
413
|
const name = plugin.manifest.name;
|
package/src/hooks/hook-loader.ts
CHANGED
|
@@ -25,9 +25,9 @@ import { join } from "node:path";
|
|
|
25
25
|
import { getConfig } from "../config/loader.js";
|
|
26
26
|
import { HOOKS } from "../plugin-api/constants.js";
|
|
27
27
|
import type {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
HookFunction,
|
|
29
|
+
InitContext,
|
|
30
|
+
ShutdownContext,
|
|
31
31
|
} from "../plugin-api/types.js";
|
|
32
32
|
import { listSurfaceDir } from "../plugins/external-plugin-loader.js";
|
|
33
33
|
import { getMtime, importWithTimeout } from "../plugins/surface-import.js";
|
|
@@ -53,7 +53,7 @@ export const WORKSPACE_HOOKS_OWNER = "__workspace__";
|
|
|
53
53
|
* mtime changes, the hook is re-imported and the entry is replaced.
|
|
54
54
|
*/
|
|
55
55
|
interface CachedHook {
|
|
56
|
-
readonly hook:
|
|
56
|
+
readonly hook: HookFunction;
|
|
57
57
|
/** mtimeMs of the source file this hook was imported from. */
|
|
58
58
|
readonly sourceMtime: number;
|
|
59
59
|
}
|
|
@@ -83,7 +83,7 @@ async function resolveCachedHook<TCtx>(
|
|
|
83
83
|
ownerName: string,
|
|
84
84
|
hookName: string,
|
|
85
85
|
filePath: string,
|
|
86
|
-
): Promise<
|
|
86
|
+
): Promise<HookFunction<TCtx> | undefined> {
|
|
87
87
|
const key = hookKey(ownerName, hookName);
|
|
88
88
|
const currentMtime = getMtime(filePath);
|
|
89
89
|
|
|
@@ -94,7 +94,7 @@ async function resolveCachedHook<TCtx>(
|
|
|
94
94
|
cached.sourceMtime === currentMtime &&
|
|
95
95
|
currentMtime > 0
|
|
96
96
|
) {
|
|
97
|
-
return cached.hook as
|
|
97
|
+
return cached.hook as HookFunction<TCtx>;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
// Cache miss — re-import.
|
|
@@ -105,7 +105,7 @@ async function resolveCachedHook<TCtx>(
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
try {
|
|
108
|
-
const hook = await importWithTimeout<
|
|
108
|
+
const hook = await importWithTimeout<HookFunction>(filePath);
|
|
109
109
|
if (hook === undefined || typeof hook !== "function") {
|
|
110
110
|
log.error(
|
|
111
111
|
{ plugin: ownerName, hook: hookName, path: filePath },
|
|
@@ -114,7 +114,7 @@ async function resolveCachedHook<TCtx>(
|
|
|
114
114
|
return undefined;
|
|
115
115
|
}
|
|
116
116
|
hookCache.set(key, { hook, sourceMtime: currentMtime });
|
|
117
|
-
return hook as
|
|
117
|
+
return hook as HookFunction<TCtx>;
|
|
118
118
|
} catch (err) {
|
|
119
119
|
log.error(
|
|
120
120
|
{ err, plugin: ownerName, hook: hookName, path: filePath },
|
|
@@ -141,8 +141,8 @@ async function resolveCachedHook<TCtx>(
|
|
|
141
141
|
export async function collectUserHooks<TCtx = unknown>(
|
|
142
142
|
hookName: string,
|
|
143
143
|
pluginDirs: Iterable<readonly [string, string]>,
|
|
144
|
-
): Promise<
|
|
145
|
-
const out:
|
|
144
|
+
): Promise<HookFunction<TCtx>[]> {
|
|
145
|
+
const out: HookFunction<TCtx>[] = [];
|
|
146
146
|
|
|
147
147
|
for (const [pluginDir, pluginName] of pluginDirs) {
|
|
148
148
|
const hookFile = listSurfaceDir(join(pluginDir, "hooks")).find(
|
|
@@ -191,7 +191,7 @@ export async function preImportHooksDir(
|
|
|
191
191
|
if (currentMtime === 0) continue;
|
|
192
192
|
|
|
193
193
|
try {
|
|
194
|
-
const hook = await importWithTimeout<
|
|
194
|
+
const hook = await importWithTimeout<HookFunction>(file.path);
|
|
195
195
|
if (hook !== undefined && typeof hook === "function") {
|
|
196
196
|
hookCache.set(key, { hook, sourceMtime: currentMtime });
|
|
197
197
|
}
|
|
@@ -233,7 +233,7 @@ export async function runInitHook(ownerName: string): Promise<void> {
|
|
|
233
233
|
if (initHookEntry === undefined) return;
|
|
234
234
|
|
|
235
235
|
try {
|
|
236
|
-
const initContext:
|
|
236
|
+
const initContext: InitContext = {
|
|
237
237
|
config: getConfig().plugins?.[ownerName],
|
|
238
238
|
logger: log.child({ plugin: ownerName }),
|
|
239
239
|
pluginStorageDir: ensureHookStorageDir(ownerName),
|
|
@@ -256,7 +256,7 @@ export async function runInitHook(ownerName: string): Promise<void> {
|
|
|
256
256
|
*/
|
|
257
257
|
export async function runShutdownHook(
|
|
258
258
|
ownerName: string,
|
|
259
|
-
context:
|
|
259
|
+
context: ShutdownContext,
|
|
260
260
|
reason: string,
|
|
261
261
|
): Promise<void> {
|
|
262
262
|
const shutdownHookEntry = hookCache.get(hookKey(ownerName, HOOKS.SHUTDOWN));
|
package/src/plugin-api/index.ts
CHANGED
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
* (optionally overriding the profile) and run inference through the
|
|
38
38
|
* workspace's configured profiles and credentials — no plugin-supplied API key
|
|
39
39
|
*
|
|
40
|
-
* - {@link
|
|
41
|
-
* - {@link
|
|
40
|
+
* - {@link InitContext} — passed to `init` hook at bootstrap
|
|
41
|
+
* - {@link ShutdownContext} — passed to `shutdown` hook at teardown
|
|
42
42
|
* - {@link UserPromptSubmitContext} — passed to `user-prompt-submit` hook,
|
|
43
43
|
* fired immediately before the agent loop receives a user's prompt
|
|
44
44
|
* - {@link PostCompactContext} — passed to `post-compact` hook, fired after
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
* - {@link PostModelCallContext} — passed to `post-model-call` hook, fired at
|
|
56
56
|
* every model-call outcome (a finalized reply or a provider rejection) to
|
|
57
57
|
* transform content and decide whether to retry
|
|
58
|
-
* - {@link
|
|
58
|
+
* - {@link HookFunction} — signature every lifecycle hook implements
|
|
59
59
|
* - {@link PluginLogger} — pino-compatible logger shape on the contexts
|
|
60
60
|
* - {@link ToolDefinition} — author-facing tool spec (default-export shape
|
|
61
61
|
* for both plugin tool files and workspace tool files)
|
|
@@ -98,16 +98,16 @@ export type {
|
|
|
98
98
|
export type { LLMCallSite } from "../config/schemas/llm.js";
|
|
99
99
|
export type {
|
|
100
100
|
AgentLoopExitReason,
|
|
101
|
+
HookFunction,
|
|
102
|
+
InitContext,
|
|
101
103
|
ModelProfileInfo,
|
|
102
|
-
PluginHookFn,
|
|
103
|
-
PluginInitContext,
|
|
104
104
|
PluginLogger,
|
|
105
|
-
PluginShutdownContext,
|
|
106
105
|
PostCompactContext,
|
|
107
106
|
PostModelCallContext,
|
|
108
107
|
PostModelCallDecision,
|
|
109
108
|
PostToolUseContext,
|
|
110
109
|
PreModelCallContext,
|
|
110
|
+
ShutdownContext,
|
|
111
111
|
StopContext,
|
|
112
112
|
ToolContext,
|
|
113
113
|
ToolDefinition,
|