@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 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.202606251104.36cd100",
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
+ });
@@ -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
@@ -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
- // Evict cache entries for deleted plugins.
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
- // ─── Boot population ─────────────────────────────────────────────────────────
424
+ // ─── Activation lifecycle ────────────────────────────────────────────────────
414
425
 
415
426
  /**
416
- * Plugins (and the workspace-hooks pseudo-owner) that were fully activated at
417
- * boot (tools registered + init hook run). Used by the shutdown hook to tear
418
- * down only what was actually brought up.
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
- * Populate the caches at boot by scanning the plugins directory once,
424
- * importing all surfaces, registering tools into the tool registry,
425
- * running `init` hooks, and installing a shutdown hook.
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
- * into the tool registry and cache hooks by mtime. The `init` and
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
- const shutdownSnapshot = [...activatedPlugins];
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 = shutdownSnapshot.length - 1; i >= 0; i--) {
497
- const { name } = shutdownSnapshot[i]!;
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
 
@@ -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.