@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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/mtime-cache.test.ts +130 -1
  3. package/src/__tests__/plugin-bootstrap.test.ts +5 -5
  4. package/src/__tests__/plugin-route-contribution.test.ts +2 -2
  5. package/src/__tests__/plugin-tool-contribution.test.ts +2 -2
  6. package/src/__tests__/plugin-types.test.ts +2 -2
  7. package/src/cli/commands/plugins.ts +7 -18
  8. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +35 -0
  9. package/src/daemon/conversation-tool-setup.ts +25 -8
  10. package/src/daemon/external-plugins-bootstrap.ts +5 -5
  11. package/src/hooks/hook-loader.ts +13 -13
  12. package/src/plugin-api/index.ts +6 -6
  13. package/src/plugin-api/types.ts +9 -9
  14. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +2 -2
  15. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +2 -2
  16. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +2 -2
  17. package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +2 -2
  18. package/src/plugins/defaults/empty-response/hooks/stop.ts +2 -2
  19. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +2 -2
  20. package/src/plugins/defaults/history-repair/hooks/post-model-call.ts +2 -2
  21. package/src/plugins/defaults/history-repair/hooks/stop.ts +2 -2
  22. package/src/plugins/defaults/history-repair/hooks/user-prompt-submit.ts +2 -2
  23. package/src/plugins/defaults/image-fallback/hooks/post-tool-use.ts +2 -2
  24. package/src/plugins/defaults/image-fallback/hooks/user-prompt-submit.ts +2 -2
  25. package/src/plugins/defaults/image-recovery/hooks/post-model-call.ts +2 -2
  26. package/src/plugins/defaults/image-recovery/hooks/stop.ts +2 -2
  27. package/src/plugins/defaults/max-tokens-continue/hooks/post-model-call.ts +2 -2
  28. package/src/plugins/defaults/max-tokens-continue/hooks/stop.ts +2 -2
  29. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +2 -2
  30. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +2 -2
  31. package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +2 -2
  32. package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +2 -2
  33. package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +2 -2
  34. package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +2 -2
  35. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +2 -2
  36. package/src/plugins/defaults/title-generate/hooks/stop.ts +2 -2
  37. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +2 -2
  38. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +2 -2
  39. package/src/plugins/defaults/tool-result-truncate/hooks/post-tool-use.ts +2 -2
  40. package/src/plugins/external-plugin-loader.ts +2 -2
  41. package/src/plugins/mtime-cache.ts +130 -59
  42. package/src/plugins/pipeline.ts +2 -2
  43. package/src/plugins/registry.ts +5 -5
  44. package/src/plugins/types.ts +7 -7
  45. package/src/tools/registry.ts +13 -0
  46. package/src/tools/types.ts +114 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.2-dev.202606250916.8422b74",
3
+ "version": "0.10.2-dev.202606251257.2eba8a4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 { mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
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 `PluginInitContext` that exposes every
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 Plugin, type PluginInitContext } from "../plugins/types.js";
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: PluginInitContext) => Promise<void>;
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 PluginInitContext", async () => {
102
- let received: PluginInitContext | undefined;
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 { Plugin, PluginInitContext } from "../plugins/types.js";
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: PluginInitContext) => Promise<void>;
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 { Plugin, PluginInitContext } from "../plugins/types.js";
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: PluginInitContext) => Promise<void>;
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: PluginInitContext) {
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. Restart the assistant for the change to take effect.",
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. Restart the assistant for the change to take effect.",
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 two dynamic
678
- // categories (MCP, workspace). We keep core tools from the snapshot and
679
- // re-read MCP + workspace tools from the registry each turn.
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) => !initialMcpNames.has(d.name) && !initialWorkspaceNames.has(d.name),
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
- // Filter core tools based on current conversation context so that tools
713
- // irrelevant to this turn (e.g. UI tools when no client is connected)
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((d) =>
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 PluginInitContext.pluginStorageDir}.
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 PluginShutdownContext,
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 PluginShutdownContext}.
226
- const shutdownContext: PluginShutdownContext = {
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: PluginShutdownContext,
410
+ shutdownContext: ShutdownContext,
411
411
  ): Promise<void> {
412
412
  const { plugin, routeHandles } = active;
413
413
  const name = plugin.manifest.name;
@@ -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
- PluginHookFn,
29
- PluginInitContext,
30
- PluginShutdownContext,
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: PluginHookFn;
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<PluginHookFn<TCtx> | undefined> {
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 PluginHookFn<TCtx>;
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<PluginHookFn>(filePath);
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 PluginHookFn<TCtx>;
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<PluginHookFn<TCtx>[]> {
145
- const out: PluginHookFn<TCtx>[] = [];
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<PluginHookFn>(file.path);
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: PluginInitContext = {
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: PluginShutdownContext,
259
+ context: ShutdownContext,
260
260
  reason: string,
261
261
  ): Promise<void> {
262
262
  const shutdownHookEntry = hookCache.get(hookKey(ownerName, HOOKS.SHUTDOWN));
@@ -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 PluginInitContext} — passed to `init` hook at bootstrap
41
- * - {@link PluginShutdownContext} — passed to `shutdown` hook at teardown
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 PluginHookFn} — signature every lifecycle hook implements
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,