@vellumai/assistant 0.10.6-staging.1 → 0.10.6

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 (58) hide show
  1. package/openapi.yaml +43 -6
  2. package/package.json +1 -1
  3. package/src/__tests__/attachment-stored-path-annotation.test.ts +151 -0
  4. package/src/__tests__/attachments-store.test.ts +37 -1
  5. package/src/__tests__/attachments.test.ts +53 -0
  6. package/src/__tests__/channel-reply-delivery.test.ts +214 -5
  7. package/src/__tests__/config-loader-backfill.test.ts +16 -36
  8. package/src/__tests__/image-source-path-reinject.test.ts +155 -14
  9. package/src/__tests__/managed-profile-guard.test.ts +2 -0
  10. package/src/__tests__/mtime-cache.test.ts +265 -80
  11. package/src/__tests__/plugin-config-data-migration.test.ts +2 -4
  12. package/src/__tests__/plugin-effective-enabled-set.test.ts +9 -6
  13. package/src/__tests__/workspace-migration-125-repoint-managed-connections-to-vellum.test.ts +140 -0
  14. package/src/agent/attachments.ts +55 -13
  15. package/src/cli/commands/platform/__tests__/credits.test.ts +111 -0
  16. package/src/cli/commands/platform/index.ts +86 -4
  17. package/src/cli/lib/__tests__/confirm-prompt.test.ts +9 -9
  18. package/src/cli/lib/diff-plugin.ts +7 -3
  19. package/src/cli/lib/inspect-plugin.ts +28 -10
  20. package/src/cli/lib/install-from-github.ts +69 -24
  21. package/src/cli/lib/ipc-params.ts +3 -3
  22. package/src/cli/lib/list-installed-plugins.ts +11 -5
  23. package/src/cli/lib/merge-plugin-tree.ts +15 -9
  24. package/src/cli/lib/plugin-fingerprint.ts +0 -6
  25. package/src/cli/lib/publish-plugin.ts +1 -1
  26. package/src/cli/lib/unknown-command.ts +3 -1
  27. package/src/cli/lib/upgrade-plugin.ts +2 -5
  28. package/src/config/__tests__/sync-gated-profiles.test.ts +2 -2
  29. package/src/config/seed-inference-profiles.ts +5 -4
  30. package/src/daemon/conversation-lifecycle.ts +37 -15
  31. package/src/daemon/conversation-messaging.ts +56 -28
  32. package/src/daemon/conversation.ts +2 -2
  33. package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
  34. package/src/hooks/__tests__/hook-live-reload.test.ts +110 -96
  35. package/src/hooks/hook-loader.ts +57 -144
  36. package/src/hooks/registry.ts +13 -9
  37. package/src/persistence/attachments-store.ts +2 -1
  38. package/src/persistence/conversation-crud.ts +24 -0
  39. package/src/plugin-api/types.ts +4 -1
  40. package/src/plugins/mtime-cache.ts +347 -33
  41. package/src/plugins/pipeline.ts +3 -2
  42. package/src/providers/__tests__/inference.test.ts +42 -31
  43. package/src/providers/inference/__tests__/connections-label.test.ts +9 -28
  44. package/src/providers/inference/auth.ts +1 -2
  45. package/src/providers/inference/backfill.ts +7 -5
  46. package/src/providers/inference/connections.ts +54 -30
  47. package/src/runtime/channel-reply-delivery.ts +83 -24
  48. package/src/runtime/channel-retry-sweep.ts +4 -0
  49. package/src/runtime/{slack-no-response.ts → no-response.ts} +26 -6
  50. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +43 -3
  51. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +61 -24
  52. package/src/runtime/routes/conversation-query-routes.ts +18 -1
  53. package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -1
  54. package/src/runtime/routes/inference-provider-connection-routes.ts +4 -3
  55. package/src/runtime/routes/platform-routes.ts +82 -1
  56. package/src/runtime/slack-reply-session.ts +1 -1
  57. package/src/workspace/migrations/125-repoint-managed-connections-to-vellum.ts +99 -0
  58. package/src/workspace/migrations/registry.ts +2 -0
@@ -9,14 +9,21 @@
9
9
  * plugins directory, registers tools, and drives hook pre-import / init /
10
10
  * shutdown by handing the discovered directories to the hook loader.
11
11
  *
12
- * - A changed tool file triggers a re-import of just that tool, not a full
13
- * plugin rebuild. Added and removed surface files are picked up live, since
14
- * discovery is by directory listing.
12
+ * - Boot does one full discovery scan; after that, every change (plugin
13
+ * installed, removed, disabled, or any source file inside a plugin edited
14
+ * — including helper modules hooks/tools import) arrives through the
15
+ * source-versions sentinel published by the resource monitor's watcher.
16
+ * The dispatch path stats that one file and otherwise runs on memory.
17
+ * - A changed plugin is redeployed in place: the old version's `shutdown`
18
+ * runs, its hook/tool cache entries and module-registry entries are
19
+ * swept, and reactivation re-imports fresh source and runs `init`. The
20
+ * whole directory is the reload unit so a re-imported hook can never pair
21
+ * with a stale cached helper.
15
22
  * - Plugins are never "registered" as a unit — we register their tools into
16
- * the global tool registry and cache-bust them using mtime on reads.
23
+ * the global tool registry.
17
24
  *
18
- * The cache is populated at boot by `loadUserPlugins()` and read on every
19
- * `getHooksFor` / `getAllTools` call.
25
+ * The cache is populated at boot by `loadUserPlugins()`; `getHooksFor`
26
+ * reads it on every dispatch via {@link getUserHookEntriesFor}.
20
27
  */
21
28
 
22
29
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
@@ -42,15 +49,25 @@ import {
42
49
  import { finalizeTool } from "../tools/tool-defaults.js";
43
50
  import type { Tool, ToolDefinition } from "../tools/types.js";
44
51
  import { getLogger } from "../util/logger.js";
45
- import { getWorkspacePluginsDir } from "../util/platform.js";
52
+ import {
53
+ getWorkspaceHooksDir,
54
+ getWorkspacePluginsDir,
55
+ } from "../util/platform.js";
46
56
  import { APP_VERSION } from "../version.js";
47
57
  import {
48
58
  deriveToolName,
49
59
  listSurfaceDir,
50
60
  parsePluginManifest,
51
61
  } from "./external-plugin-loader.js";
62
+ import { snapshotPluginSource } from "./source-fingerprint.js";
63
+ import type { PluginSourceVersion } from "./source-versions.js";
64
+ import {
65
+ getSourceVersionsPath,
66
+ readSourceVersions,
67
+ } from "./source-versions.js";
52
68
  import {
53
69
  clearSurfaceImportInflight,
70
+ evictModule,
54
71
  getMtime,
55
72
  importWithTimeout,
56
73
  setSurfaceImportTimeout,
@@ -93,7 +110,9 @@ const INSTALL_META_FILENAME = "install-meta.json";
93
110
  */
94
111
  function getInstallDate(pluginDir: string): number {
95
112
  const cached = installDateCache.get(pluginDir);
96
- if (cached !== undefined) return cached;
113
+ if (cached !== undefined) {
114
+ return cached;
115
+ }
97
116
 
98
117
  // Try install-meta.json first.
99
118
  const metaPath = join(pluginDir, INSTALL_META_FILENAME);
@@ -171,13 +190,29 @@ const discoveredPluginDirs = new Map<string, string>();
171
190
  */
172
191
  const disabledPluginDirs = new Set<string>();
173
192
 
193
+ /**
194
+ * The source-versions state this process last applied, keyed by directory
195
+ * (see `./source-versions.ts`). Seeded at boot from the daemon's own walk —
196
+ * the same fingerprint algorithm over the same disk yields the same stamps
197
+ * as the watcher, so the first sentinel publication after boot diffs
198
+ * correctly even against edits made while the daemon was down.
199
+ */
200
+ let lastVersions: Record<string, PluginSourceVersion> = {};
201
+
202
+ /** mtime of the sentinel document as of the last look; 0 = never seen. */
203
+ let lastSentinelMtime = 0;
204
+
205
+ /** In-flight reconcile — concurrent dispatches await it rather than racing. */
206
+ let reconcileInFlight: Promise<void> | null = null;
207
+
174
208
  // ─── Hook reads ──────────────────────────────────────────────────────────────
175
209
 
176
210
  /**
177
211
  * Get all hooks for a given event name from user plugins and standalone
178
- * workspace hooks. Refreshes plugin discovery first, then delegates the actual
179
- * hook resolution to the hook loader. Plugin hooks run in install-date order,
180
- * the workspace hook runs last.
212
+ * workspace hooks. Checks the source-versions sentinel first (one stat;
213
+ * reconciles only when the watcher published a change), then delegates to
214
+ * the hook loader's in-memory cache. Plugin hooks run in install-date
215
+ * order, the workspace hook runs last.
181
216
  *
182
217
  * `effectiveEnabledPlugins` carries the per-chat plugin scope: when non-null,
183
218
  * user plugins outside the set are skipped (standalone workspace hooks always
@@ -187,7 +222,7 @@ export async function getUserHookEntriesFor<TCtx = unknown>(
187
222
  hookName: string,
188
223
  effectiveEnabledPlugins?: Set<string> | null,
189
224
  ): Promise<HookEntry<TCtx>[]> {
190
- await scanPlugins();
225
+ await maybeReconcileFromSentinel();
191
226
  return collectUserHookEntries<TCtx>(
192
227
  hookName,
193
228
  discoveredPluginDirs,
@@ -210,6 +245,246 @@ export async function getUserHooksFor<TCtx = unknown>(
210
245
  return entries.map((e) => e.fn);
211
246
  }
212
247
 
248
+ // ─── Source-versions reconcile ───────────────────────────────────────────────
249
+
250
+ /**
251
+ * Dispatch-path gate: one stat of the source-versions sentinel. When its
252
+ * mtime is unchanged since the last look (the overwhelmingly common case)
253
+ * this returns immediately, and dispatch runs entirely on memory. When the
254
+ * watcher published a change, the document is applied before the dispatch
255
+ * proceeds, so the turn that follows an edit already runs the new code.
256
+ *
257
+ * A missing or unreadable sentinel is degraded mode, not an error: the
258
+ * boot-time state keeps serving, and live reload resumes when the monitor
259
+ * publishes again.
260
+ */
261
+ async function maybeReconcileFromSentinel(): Promise<void> {
262
+ if (reconcileInFlight !== null) {
263
+ await reconcileInFlight;
264
+ return;
265
+ }
266
+ const mtime = getMtime(getSourceVersionsPath());
267
+ if (mtime === lastSentinelMtime) {
268
+ return;
269
+ }
270
+ // Claim the mtime before any async work, so a concurrent dispatch either
271
+ // finds the in-flight promise above or skips on the updated mtime.
272
+ lastSentinelMtime = mtime;
273
+ if (mtime === 0) {
274
+ return;
275
+ }
276
+ const doc = readSourceVersions();
277
+ if (doc === null) {
278
+ return;
279
+ }
280
+ reconcileInFlight = applySourceVersions(doc.plugins).finally(() => {
281
+ reconcileInFlight = null;
282
+ });
283
+ await reconcileInFlight;
284
+ }
285
+
286
+ /**
287
+ * Apply a published source-versions map: diff it against the state last
288
+ * applied and redeploy exactly what changed. Never throws — a failed apply
289
+ * is logged and the next publication retries from the sentinel's truth.
290
+ *
291
+ * Per directory, the transitions are:
292
+ * - present + enabled with a moved fingerprint → in-place redeploy:
293
+ * `shutdown` (reason `reload`) → sweep hook/tool caches and the module
294
+ * registry → re-import, re-register tools, `init`. The whole directory is
295
+ * the reload unit on purpose: partial eviction would let a re-imported
296
+ * hook pair with a stale cached helper, silently mixing versions.
297
+ * - newly present (installed, or `.disabled` removed) → bring up.
298
+ * - gone, or newly disabled → tear down (`uninstall` / `disable`).
299
+ * The workspace hooks pseudo-entry gets the same treatment through its own
300
+ * `init`/`shutdown` lifecycle.
301
+ */
302
+ async function applySourceVersions(
303
+ next: Readonly<Record<string, PluginSourceVersion>>,
304
+ ): Promise<void> {
305
+ try {
306
+ const workspaceHooksDir = getWorkspaceHooksDir();
307
+ const prev = lastVersions;
308
+ const dirs = new Set([...Object.keys(prev), ...Object.keys(next)]);
309
+ let membershipChanged = false;
310
+
311
+ for (const dir of dirs) {
312
+ if (dir === workspaceHooksDir) {
313
+ continue;
314
+ }
315
+ const before = prev[dir];
316
+ const after = next[dir];
317
+ const activeName = discoveredPluginDirs.get(dir);
318
+ const shouldBeUp = after !== undefined && !after.disabled;
319
+
320
+ if (activeName !== undefined && !shouldBeUp) {
321
+ const reason: ShutdownReason =
322
+ after === undefined ? "uninstall" : "disable";
323
+ await deactivatePlugin(activeName, reason);
324
+ await evictPlugin(dir, activeName);
325
+ sweepModules(before, after);
326
+ membershipChanged = true;
327
+ } else if (activeName === undefined && shouldBeUp) {
328
+ // Reinstalls land at the same path: sweep any modules cached from a
329
+ // prior install before the fresh import.
330
+ sweepModules(before, after);
331
+ if (await bringUpPlugin(dir)) {
332
+ membershipChanged = true;
333
+ }
334
+ } else if (
335
+ activeName !== undefined &&
336
+ before !== undefined &&
337
+ after !== undefined &&
338
+ before.fingerprint !== after.fingerprint
339
+ ) {
340
+ log.info(
341
+ { plugin: activeName, dir },
342
+ "plugin source changed — reloading",
343
+ );
344
+ // Shutdown reads the old version's hook from the cache, so it must
345
+ // run before the hook cache is cleared.
346
+ await deactivatePlugin(activeName, "reload");
347
+ evictHooksForOwner(activeName);
348
+ evictToolCacheEntries(activeName);
349
+ sweepModules(before, after);
350
+ // Re-read the manifest — the edit may have renamed the plugin (or
351
+ // broken the manifest, in which case the plugin stays down until a
352
+ // later edit fixes it and moves the fingerprint again).
353
+ const manifest = await parsePluginManifest(dir);
354
+ if (manifest === undefined) {
355
+ discoveredPluginDirs.delete(dir);
356
+ membershipChanged = true;
357
+ } else {
358
+ if (manifest.name !== activeName) {
359
+ discoveredPluginDirs.set(dir, manifest.name);
360
+ membershipChanged = true;
361
+ }
362
+ await reconcilePluginTools(dir, manifest.name);
363
+ await activatePlugin(dir, manifest.name);
364
+ }
365
+ }
366
+ }
367
+
368
+ if (membershipChanged) {
369
+ sortDiscoveredPluginDirs();
370
+ }
371
+
372
+ await reconcileWorkspaceHooks(
373
+ prev[workspaceHooksDir],
374
+ next[workspaceHooksDir],
375
+ );
376
+
377
+ lastVersions = { ...next };
378
+ } catch (err) {
379
+ log.error(
380
+ { err },
381
+ "source-versions reconcile failed — keeping current state",
382
+ );
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Bring up a directory the sentinel reports as present and enabled. Returns
388
+ * whether the plugin joined the discovered set (a malformed manifest is
389
+ * logged by the parser and the directory is skipped until it changes again).
390
+ */
391
+ async function bringUpPlugin(dir: string): Promise<boolean> {
392
+ const manifest = await parsePluginManifest(dir);
393
+ if (manifest === undefined) {
394
+ return false;
395
+ }
396
+ discoveredPluginDirs.set(dir, manifest.name);
397
+ disabledPluginDirs.delete(dir);
398
+ log.info({ plugin: manifest.name, dir }, "plugin discovered");
399
+ await reconcilePluginTools(dir, manifest.name);
400
+ await activatePlugin(dir, manifest.name);
401
+ return true;
402
+ }
403
+
404
+ /**
405
+ * Evict the union of two version entries' module paths from the module
406
+ * registry, so re-imports re-evaluate a mutually consistent set (deleted
407
+ * files may still be cached — hence the union, not just the new list).
408
+ */
409
+ function sweepModules(
410
+ before: PluginSourceVersion | undefined,
411
+ after: PluginSourceVersion | undefined,
412
+ ): void {
413
+ const paths = new Set([
414
+ ...(before?.evictionPaths ?? []),
415
+ ...(after?.evictionPaths ?? []),
416
+ ]);
417
+ for (const path of paths) {
418
+ evictModule(path);
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Reconcile the standalone workspace hooks pseudo-entry: same
424
+ * shutdown → sweep → re-import → init cycle a plugin gets, through the
425
+ * workspace owner's lifecycle.
426
+ */
427
+ async function reconcileWorkspaceHooks(
428
+ before: PluginSourceVersion | undefined,
429
+ after: PluginSourceVersion | undefined,
430
+ ): Promise<void> {
431
+ if (before?.fingerprint === after?.fingerprint) {
432
+ return;
433
+ }
434
+ const reason: ShutdownReason = after === undefined ? "uninstall" : "reload";
435
+ const activeIdx = activatedPlugins.findIndex(
436
+ (p) => p.name === WORKSPACE_HOOKS_OWNER,
437
+ );
438
+ if (activeIdx >= 0) {
439
+ await runShutdownHook(
440
+ WORKSPACE_HOOKS_OWNER,
441
+ { assistantVersion: APP_VERSION, reason },
442
+ reason,
443
+ );
444
+ activatedPlugins.splice(activeIdx, 1);
445
+ }
446
+ evictHooksForOwner(WORKSPACE_HOOKS_OWNER);
447
+ sweepModules(before, after);
448
+ if (after !== undefined) {
449
+ await preImportWorkspaceHooks();
450
+ await runInitHook(WORKSPACE_HOOKS_OWNER);
451
+ activatedPlugins.push({ name: WORKSPACE_HOOKS_OWNER });
452
+ log.info("workspace hooks reloaded");
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Seed the reconcile baseline at boot from the daemon's own walk, and record
458
+ * the sentinel's current mtime without applying its content. Both sides run
459
+ * the same fingerprint algorithm over the same disk, so the seed matches
460
+ * whatever a healthy watcher would publish for the state boot just loaded —
461
+ * and the first publication that differs (including edits made while the
462
+ * daemon was down, which the watcher detects against its own adopted state)
463
+ * diffs correctly against it.
464
+ */
465
+ function seedVersionBaseline(): void {
466
+ const seeded: Record<string, PluginSourceVersion> = {};
467
+ for (const [dir] of discoveredPluginDirs) {
468
+ const snapshot = snapshotPluginSource(dir);
469
+ seeded[dir] = {
470
+ fingerprint: snapshot.fingerprint,
471
+ evictionPaths: snapshot.evictionPaths,
472
+ disabled: false,
473
+ };
474
+ }
475
+ const workspaceHooksDir = getWorkspaceHooksDir();
476
+ if (existsSync(workspaceHooksDir)) {
477
+ const snapshot = snapshotPluginSource(workspaceHooksDir);
478
+ seeded[workspaceHooksDir] = {
479
+ fingerprint: snapshot.fingerprint,
480
+ evictionPaths: snapshot.evictionPaths,
481
+ disabled: false,
482
+ };
483
+ }
484
+ lastVersions = seeded;
485
+ lastSentinelMtime = getMtime(getSourceVersionsPath());
486
+ }
487
+
213
488
  // ─── Tool cache ──────────────────────────────────────────────────────────────
214
489
 
215
490
  /**
@@ -282,7 +557,9 @@ async function reconcilePluginTools(
282
557
  // Evict cached tools whose files no longer exist on disk.
283
558
  for (const key of toolCache.keys()) {
284
559
  const [cachedPluginName, cachedToolName] = key.split("/");
285
- if (cachedPluginName !== pluginName) continue;
560
+ if (cachedPluginName !== pluginName) {
561
+ continue;
562
+ }
286
563
  if (!onDiskNames.has(cachedToolName)) {
287
564
  toolCache.delete(key);
288
565
  }
@@ -303,10 +580,10 @@ export function getCachedUserTools(): Tool[] {
303
580
  * Scan the plugins directory, update the discovered set, and reconcile
304
581
  * tools for each plugin. Also evicts cache entries for deleted plugins.
305
582
  *
306
- * This is the "pull" — called on every hook read and at boot. The cost
307
- * is one `readdirSync` + N `statSync` calls where N = total surface files
308
- * across all plugins. For a typical workspace with 2-3 plugins, that's
309
- * ~10-15 stats — sub-millisecond.
583
+ * Runs once at boot ({@link populateCacheAtBoot}); steady-state changes —
584
+ * installs, removals, disables, and source edits — arrive through the
585
+ * source-versions reconcile instead, so the dispatch path never pays for
586
+ * this walk.
310
587
  */
311
588
  async function scanPlugins(): Promise<void> {
312
589
  const pluginsDir = getWorkspacePluginsDir();
@@ -330,11 +607,15 @@ async function scanPlugins(): Promise<void> {
330
607
  for (const entry of entries) {
331
608
  const pluginDir = join(pluginsDir, entry);
332
609
  try {
333
- if (!statSync(pluginDir).isDirectory()) continue;
610
+ if (!statSync(pluginDir).isDirectory()) {
611
+ continue;
612
+ }
334
613
  } catch {
335
614
  continue;
336
615
  }
337
- if (!existsSync(join(pluginDir, "package.json"))) continue;
616
+ if (!existsSync(join(pluginDir, "package.json"))) {
617
+ continue;
618
+ }
338
619
 
339
620
  // Check for the .disabled sentinel. A plugin is disabled when a file
340
621
  // named `.disabled` exists inside its plugin directory. Disabled
@@ -358,7 +639,9 @@ async function scanPlugins(): Promise<void> {
358
639
  }
359
640
 
360
641
  const manifest = await parsePluginManifest(pluginDir);
361
- if (manifest === undefined) continue;
642
+ if (manifest === undefined) {
643
+ continue;
644
+ }
362
645
  const { name: pluginName } = manifest;
363
646
 
364
647
  currentDirs.set(pluginDir, pluginName);
@@ -383,12 +666,10 @@ async function scanPlugins(): Promise<void> {
383
666
  // Update the discovered set, sorted by original install date so
384
667
  // hook execution order and tool registration order are deterministic.
385
668
  discoveredPluginDirs.clear();
386
- const sorted = [...currentDirs.entries()].sort(
387
- ([dirA], [dirB]) => getInstallDate(dirA) - getInstallDate(dirB),
388
- );
389
- for (const [dir, name] of sorted) {
669
+ for (const [dir, name] of currentDirs) {
390
670
  discoveredPluginDirs.set(dir, name);
391
671
  }
672
+ sortDiscoveredPluginDirs();
392
673
 
393
674
  // Activate any plugin not yet brought up. Idempotent: already-active plugins
394
675
  // are skipped by the `activatedNames` guard, so steady-state scans (one per
@@ -400,6 +681,21 @@ async function scanPlugins(): Promise<void> {
400
681
  }
401
682
  }
402
683
 
684
+ /**
685
+ * Re-sort the discovered set by original install date, so hook execution
686
+ * order and tool registration order stay deterministic as membership
687
+ * changes at runtime.
688
+ */
689
+ function sortDiscoveredPluginDirs(): void {
690
+ const sorted = [...discoveredPluginDirs.entries()].sort(
691
+ ([dirA], [dirB]) => getInstallDate(dirA) - getInstallDate(dirB),
692
+ );
693
+ discoveredPluginDirs.clear();
694
+ for (const [dir, name] of sorted) {
695
+ discoveredPluginDirs.set(dir, name);
696
+ }
697
+ }
698
+
403
699
  /**
404
700
  * Evict all cache entries for a deleted plugin directory. The plugin name
405
701
  * is passed in from the discoveredPluginDirs map (captured when the plugin
@@ -413,12 +709,7 @@ async function evictPlugin(
413
709
  evictHooksForOwner(pluginName);
414
710
 
415
711
  // Evict tools.
416
- const toolPrefix = `${pluginName}/`;
417
- for (const key of toolCache.keys()) {
418
- if (key.startsWith(toolPrefix)) {
419
- toolCache.delete(key);
420
- }
421
- }
712
+ evictToolCacheEntries(pluginName);
422
713
 
423
714
  log.info(
424
715
  { plugin: pluginName, pluginDir },
@@ -428,6 +719,16 @@ async function evictPlugin(
428
719
  installDateCache.delete(pluginDir);
429
720
  }
430
721
 
722
+ /** Drop every `toolCache` entry owned by `pluginName`. */
723
+ function evictToolCacheEntries(pluginName: string): void {
724
+ const toolPrefix = `${pluginName}/`;
725
+ for (const key of toolCache.keys()) {
726
+ if (key.startsWith(toolPrefix)) {
727
+ toolCache.delete(key);
728
+ }
729
+ }
730
+ }
731
+
431
732
  /**
432
733
  * Evict all plugin-owned cache entries (when the plugins directory is gone
433
734
  * entirely). Standalone workspace hooks are preserved by the hook loader:
@@ -477,7 +778,9 @@ async function activatePlugin(
477
778
  pluginDir: string,
478
779
  pluginName: string,
479
780
  ): Promise<void> {
480
- if (activatedNames.has(pluginName)) return;
781
+ if (activatedNames.has(pluginName)) {
782
+ return;
783
+ }
481
784
  // Reserve synchronously, before any await, so a re-entrant or concurrent
482
785
  // scan observes this plugin as already handled.
483
786
  activatedNames.add(pluginName);
@@ -523,10 +826,14 @@ async function deactivatePlugin(
523
826
  pluginName: string,
524
827
  reason: ShutdownReason,
525
828
  ): Promise<void> {
526
- if (!activatedNames.has(pluginName)) return;
829
+ if (!activatedNames.has(pluginName)) {
830
+ return;
831
+ }
527
832
  activatedNames.delete(pluginName);
528
833
  const idx = activatedPlugins.findIndex((p) => p.name === pluginName);
529
- if (idx >= 0) activatedPlugins.splice(idx, 1);
834
+ if (idx >= 0) {
835
+ activatedPlugins.splice(idx, 1);
836
+ }
530
837
 
531
838
  // Unregister tools before running shutdown so the model-visible surface is
532
839
  // clean before teardown.
@@ -587,6 +894,10 @@ export async function populateCacheAtBoot(
587
894
  await runInitHook(WORKSPACE_HOOKS_OWNER);
588
895
  activatedPlugins.push({ name: WORKSPACE_HOOKS_OWNER });
589
896
  }
897
+
898
+ // Boot is self-sufficient (the scan above never waits on the monitor);
899
+ // from here on, changes arrive via the sentinel diffed against this seed.
900
+ seedVersionBaseline();
590
901
  }
591
902
 
592
903
  // ─── Test hooks ──────────────────────────────────────────────────────────────
@@ -610,6 +921,9 @@ export function resetPluginCacheForTests(): void {
610
921
  activatedPlugins.length = 0;
611
922
  activatedNames.clear();
612
923
  disabledPluginDirs.clear();
924
+ lastVersions = {};
925
+ lastSentinelMtime = 0;
926
+ reconcileInFlight = null;
613
927
  }
614
928
 
615
929
  /**
@@ -9,8 +9,9 @@
9
9
  * place (returning `void`) or returns a partial context whose fields are merged
10
10
  * onto the draft. Failed hook drafts are discarded.
11
11
  *
12
- * `getHooksFor` is now async — it pulls user-land hooks from the mtime
13
- * cache (filesystem-as-truth) and default plugin hooks from the registry
12
+ * `getHooksFor` is now async — it pulls user-land hooks from the plugin
13
+ * cache (filesystem-as-truth via the source-versions reconcile) and default
14
+ * plugin hooks from the registry
14
15
  * in a single unified call.
15
16
  *
16
17
  * Design doc: `.private/plans/agent-plugin-system.md`.
@@ -53,35 +53,42 @@ describe("migrateCreateProviderConnections", () => {
53
53
  expect(Array.isArray(rows)).toBe(true);
54
54
  });
55
55
 
56
- test("seeds canonical connections on first run", () => {
56
+ test("seedCanonicalConnections seeds the provider-agnostic vellum connection", () => {
57
57
  const { db } = setupDb();
58
- const canonicals = [
59
- "anthropic-managed",
60
- "openai-managed",
61
- "gemini-managed",
62
- ];
63
- for (const name of canonicals) {
64
- const conn = getConnection(db, name);
65
- expect(conn).not.toBeNull();
66
- }
58
+ seedCanonicalConnections(db);
59
+ const vellum = getConnection(db, "vellum");
60
+ expect(vellum).not.toBeNull();
61
+ expect(vellum?.provider).toBe("vellum");
62
+ expect(vellum?.auth.type).toBe("platform");
63
+ // Derived from MANAGED_CONNECTION_NAMES → write-protected at the route layer.
64
+ expect(vellum?.isManaged).toBe(true);
67
65
  });
68
66
 
69
- test("canonical connections have correct auth types", () => {
67
+ test("seedCanonicalConnections is idempotent", () => {
70
68
  const { db } = setupDb();
71
- expect(getConnection(db, "anthropic-managed")?.auth.type).toBe("platform");
72
- expect(getConnection(db, "openai-managed")?.auth.type).toBe("platform");
73
- expect(getConnection(db, "gemini-managed")?.auth.type).toBe("platform");
69
+ // Run twice — should not throw or create duplicate vellum rows.
70
+ seedCanonicalConnections(db);
71
+ seedCanonicalConnections(db);
72
+ const managed = listConnections(db, { provider: "vellum" });
73
+ expect(managed.filter((c) => c.name === "vellum").length).toBe(1);
74
74
  });
75
75
 
76
- test("seedCanonicalConnections is idempotent", () => {
76
+ test("seedCanonicalConnections does not clobber a user connection named vellum", () => {
77
77
  const { db } = setupDb();
78
- // Run twice — should not throw or create duplicates
79
- seedCanonicalConnections(db);
78
+ // `vellum` was not reserved before consolidation, so an install may already
79
+ // have a BYOK connection keyed `vellum`. Seeding must not rewrite it to the
80
+ // platform-auth sentinel.
81
+ createConnection(db, {
82
+ name: "vellum",
83
+ provider: "anthropic",
84
+ auth: { type: "api_key", credential: "credential/anthropic/api_key" },
85
+ });
86
+
80
87
  seedCanonicalConnections(db);
81
- const managed = listConnections(db, { provider: "anthropic" });
82
- expect(managed.filter((c) => c.name === "anthropic-managed").length).toBe(
83
- 1,
84
- );
88
+
89
+ const conn = getConnection(db, "vellum");
90
+ expect(conn?.provider).toBe("anthropic");
91
+ expect(conn?.auth.type).toBe("api_key");
85
92
  });
86
93
  });
87
94
 
@@ -274,32 +281,36 @@ describe("Mix-and-match: two profiles, same provider, different connections", ()
274
281
  test("getConnection returns the right auth for each connection name", () => {
275
282
  const { db } = setupDb();
276
283
 
277
- // anthropic-managed already exists (canonical seed) with platform auth.
278
- const managedConn = getConnection(db, "anthropic-managed");
279
- expect(managedConn?.auth.type).toBe("platform");
280
-
281
- // Create a personal connection with api_key auth.
284
+ // Two connections for the same provider with distinct auth resolve
285
+ // independently: one platform-auth, one personal api_key.
286
+ createConnection(db, {
287
+ name: "anthropic-platform",
288
+ provider: "anthropic",
289
+ auth: { type: "platform" },
290
+ });
282
291
  createConnection(db, {
283
292
  name: "anthropic-personal",
284
293
  provider: "anthropic",
285
294
  auth: { type: "api_key", credential: "credential/anthropic/api_key" },
286
295
  });
287
296
 
288
- const personalConn = getConnection(db, "anthropic-personal");
289
- expect(personalConn?.auth.type).toBe("api_key");
297
+ expect(getConnection(db, "anthropic-platform")?.auth.type).toBe("platform");
298
+ expect(getConnection(db, "anthropic-personal")?.auth.type).toBe("api_key");
290
299
 
291
300
  // Both connections exist for the same provider.
292
301
  const anthropicConns = listConnections(db, { provider: "anthropic" });
293
302
  const names = anthropicConns.map((c) => c.name);
294
- expect(names).toContain("anthropic-managed");
303
+ expect(names).toContain("anthropic-platform");
295
304
  expect(names).toContain("anthropic-personal");
296
305
 
297
306
  // Auth is distinct per connection.
298
- const managed = anthropicConns.find((c) => c.name === "anthropic-managed");
307
+ const platform = anthropicConns.find(
308
+ (c) => c.name === "anthropic-platform",
309
+ );
299
310
  const personal = anthropicConns.find(
300
311
  (c) => c.name === "anthropic-personal",
301
312
  );
302
- expect(managed?.auth.type).toBe("platform");
313
+ expect(platform?.auth.type).toBe("platform");
303
314
  expect(personal?.auth.type).toBe("api_key");
304
315
  });
305
316
  });