openbot 0.5.6 → 0.5.8

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/dist/app/cli.js CHANGED
@@ -18,7 +18,7 @@ function checkNodeVersion() {
18
18
  }
19
19
  }
20
20
  checkNodeVersion();
21
- program.name('openbot').description('OpenBot CLI').version('0.5.6');
21
+ program.name('openbot').description('OpenBot CLI').version('0.5.8');
22
22
  program
23
23
  .command('start')
24
24
  .description('Start the OpenBot harness')
@@ -12,7 +12,7 @@ import { listBuiltInPlugins, parsePluginModule } from '../../services/plugins/re
12
12
  import { enrichOpenbotPluginDescriptor, listRegistryModelOptions, } from '../../services/plugins/model-registry.js';
13
13
  import { processService } from '../../services/process.js';
14
14
  import { memoryService } from '../memory/service.js';
15
- import { guessMimeType, resolveChannelFile, statChannelFile, } from './files.js';
15
+ import { guessMimeType, resolveChannelFile, statChannelFile } from './files.js';
16
16
  const resolveBaseDir = () => getBaseDir();
17
17
  const ENTITY_SVG_CANDIDATE_NAMES = ['avatar.svg', 'icon.svg', 'image.svg', 'logo.svg'];
18
18
  const toSvgDataUrl = (svg) => `data:image/svg+xml;base64,${Buffer.from(svg, 'utf-8').toString('base64')}`;
@@ -115,10 +115,7 @@ function mergeCloudSystemPluginRefs(defaults, diskRefs) {
115
115
  ];
116
116
  }
117
117
  /** No `openbot` / `bash` — storage-side effects and infra plugins only. */
118
- const STATE_DEFAULT_PLUGINS = [
119
- { id: 'storage' },
120
- { id: 'plugin-manager' },
121
- ];
118
+ const STATE_DEFAULT_PLUGINS = [{ id: 'storage' }, { id: 'plugin-manager' }];
122
119
  const STATE_AGENT_INSTRUCTIONS = 'Built-in infra agent for deterministic state reads. No conversational model is attached; handle storage, approvals, memory, and plugin marketplace events.';
123
120
  function getSystemAgentDetails(overrides) {
124
121
  const defaultPlugins = isCloudMode() ? getCloudSystemDefaultPlugins() : SYSTEM_DEFAULT_PLUGINS;
@@ -289,12 +286,37 @@ const toVariablesRecord = (raw) => {
289
286
  String(value ?? ''),
290
287
  ]));
291
288
  };
289
+ let cachedRuntimeVersion;
290
+ const getRuntimeVersion = () => {
291
+ if (cachedRuntimeVersion !== undefined)
292
+ return cachedRuntimeVersion || undefined;
293
+ try {
294
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../package.json');
295
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
296
+ cachedRuntimeVersion = typeof pkg.version === 'string' ? pkg.version : '';
297
+ }
298
+ catch {
299
+ cachedRuntimeVersion = '';
300
+ }
301
+ return cachedRuntimeVersion || undefined;
302
+ };
303
+ const readPluginPackageVersion = async (pluginDir) => {
304
+ try {
305
+ const pkg = JSON.parse(await fs.readFile(path.join(pluginDir, 'package.json'), 'utf-8'));
306
+ return typeof pkg.version === 'string' ? pkg.version : undefined;
307
+ }
308
+ catch {
309
+ return undefined;
310
+ }
311
+ };
292
312
  const listBuiltInPluginDescriptors = async () => {
313
+ const version = getRuntimeVersion();
293
314
  return listBuiltInPlugins().map((plugin) => ({
294
315
  id: plugin.id,
295
316
  name: plugin.name,
296
317
  description: plugin.description,
297
318
  builtIn: true,
319
+ version,
298
320
  image: plugin.image,
299
321
  configSchema: plugin.configSchema,
300
322
  createdAt: new Date(),
@@ -356,12 +378,16 @@ const listPluginsFromDisk = async () => {
356
378
  const parsed = parsePluginModule(module);
357
379
  if (!parsed)
358
380
  return null;
359
- const image = await resolveEntityImageDataUrl(pluginDir);
381
+ const [image, version] = await Promise.all([
382
+ resolveEntityImageDataUrl(pluginDir),
383
+ readPluginPackageVersion(pluginDir),
384
+ ]);
360
385
  return {
361
386
  id,
362
387
  name: parsed.name || id,
363
388
  description: parsed.description || '',
364
389
  builtIn: false,
390
+ version,
365
391
  image: parsed.image || image,
366
392
  configSchema: parsed.configSchema,
367
393
  createdAt: new Date(),
@@ -480,7 +506,7 @@ export const storageService = {
480
506
  channel.recentThreads = allThreads
481
507
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
482
508
  .slice(0, 5);
483
- const threadsUnseen = allThreads.some(t => t.hasUnseenMessages);
509
+ const threadsUnseen = allThreads.some((t) => t.hasUnseenMessages);
484
510
  channel.hasUnseenMessages = rootUnseen || threadsUnseen;
485
511
  }
486
512
  catch {
@@ -521,8 +547,7 @@ export const storageService = {
521
547
  finalState.cwd = resolvedCwd;
522
548
  await fs.mkdir(resolvedCwd, { recursive: true });
523
549
  await fs.mkdir(channelDir, { recursive: true });
524
- await fs.writeFile(specPath, spec?.trim() ||
525
- `# ${normalizedChannelId}\n\n`);
550
+ await fs.writeFile(specPath, spec?.trim() || `# ${normalizedChannelId}\n\n`);
526
551
  await writeJsonFileAtomically(statePath, finalState);
527
552
  },
528
553
  ensureChannel: async ({ channelId, spec, initialState, cwd, }) => {
@@ -684,9 +709,7 @@ export const storageService = {
684
709
  console.error(`Failed to read thread state for channel ${channelId} thread ${threadId}`, error);
685
710
  }
686
711
  }
687
- const threadName = isRecord(state) && typeof state.name === 'string'
688
- ? state.name.trim()
689
- : '';
712
+ const threadName = isRecord(state) && typeof state.name === 'string' ? state.name.trim() : '';
690
713
  return {
691
714
  id: threadId,
692
715
  name: threadName || threadId,
@@ -840,9 +863,7 @@ export const storageService = {
840
863
  const discoveredImage = await resolveEntityImageDataUrl(agentDir);
841
864
  const stats = await fs.stat(agentMdPath);
842
865
  const pluginRefs = parsePluginRefs(data.plugins);
843
- const frontmatterImage = typeof data.image === 'string' && data.image.trim() !== ''
844
- ? data.image.trim()
845
- : undefined;
866
+ const frontmatterImage = typeof data.image === 'string' && data.image.trim() !== '' ? data.image.trim() : undefined;
846
867
  diskDetails = {
847
868
  id: agentId,
848
869
  name: typeof data.name === 'string' ? data.name : agentId,
@@ -964,8 +985,7 @@ export const storageService = {
964
985
  // Built-in agents merge disk overlays with code defaults on read; on write, partial
965
986
  // updates (e.g. plugins-only) must still persist a complete AGENT.md.
966
987
  if (isBuiltinOverlayAgentId(agentId)) {
967
- const pluginRefs = plugins ??
968
- (nextData.plugins !== undefined ? parsePluginRefs(nextData.plugins) : undefined);
988
+ const pluginRefs = plugins ?? (nextData.plugins !== undefined ? parsePluginRefs(nextData.plugins) : undefined);
969
989
  const diskOverrides = {};
970
990
  if (typeof nextData.name === 'string' && nextData.name.trim() !== '') {
971
991
  diskOverrides.name = nextData.name;
@@ -1170,7 +1190,7 @@ export const storageService = {
1170
1190
  typeof raw === 'object' &&
1171
1191
  'variables' in raw &&
1172
1192
  Array.isArray(raw.variables)) {
1173
- const entries = (raw.variables)
1193
+ const entries = raw.variables
1174
1194
  .filter((v) => typeof v?.key === 'string')
1175
1195
  .map((v) => [v.key, { value: String(v.value ?? ''), secret: !!v.secret }]);
1176
1196
  return Object.fromEntries(entries);
@@ -1236,9 +1256,7 @@ export const storageService = {
1236
1256
  if (!baseCwd) {
1237
1257
  throw new Error('Channel has no CWD configured');
1238
1258
  }
1239
- const targetDir = subPath
1240
- ? resolveChannelFile(baseCwd, subPath)
1241
- : resolvePath(baseCwd);
1259
+ const targetDir = subPath ? resolveChannelFile(baseCwd, subPath) : resolvePath(baseCwd);
1242
1260
  const entries = await fs.readdir(targetDir, { withFileTypes: true });
1243
1261
  return entries
1244
1262
  .filter((e) => !e.name.startsWith('.'))
@@ -2,8 +2,14 @@
2
2
  export const resolvedPluginCache = new Map();
3
3
  /** Community plugin ids that have already been logged as loaded once. */
4
4
  export const loadedCommunityPlugins = new Set();
5
+ /** Bumped on invalidate so dynamic imports use a fresh Node ESM cache key. */
6
+ const pluginImportRevisions = new Map();
7
+ export function getPluginImportRevision(id) {
8
+ return pluginImportRevisions.get(id) ?? 0;
9
+ }
5
10
  /** Drop a single id from the in-memory resolver cache (e.g. after install/uninstall). */
6
11
  export function invalidatePlugin(id) {
7
12
  resolvedPluginCache.delete(id);
8
13
  loadedCommunityPlugins.delete(id);
14
+ pluginImportRevisions.set(id, (pluginImportRevisions.get(id) ?? 0) + 1);
9
15
  }
@@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url';
4
4
  import { storagePlugin } from '../../plugins/storage/index.js';
5
5
  import { pluginManagerPlugin } from '../../plugins/plugin-manager/index.js';
6
6
  import { DEFAULT_PLUGINS_DIR, getBaseDir } from '../../app/config.js';
7
- import { invalidatePlugin as clearResolvedPluginEntry, loadedCommunityPlugins, resolvedPluginCache, } from './plugin-cache.js';
7
+ import { getPluginImportRevision, invalidatePlugin as clearResolvedPluginEntry, loadedCommunityPlugins, resolvedPluginCache, } from './plugin-cache.js';
8
8
  let pluginsDir = null;
9
9
  const BUILT_IN = {
10
10
  [storagePlugin.id]: storagePlugin,
@@ -59,7 +59,8 @@ export async function resolvePlugin(id) {
59
59
  const distPath = path.join(pluginsDir, id, 'dist', 'index.js');
60
60
  if (existsSync(distPath)) {
61
61
  try {
62
- const module = await import(pathToFileURL(distPath).href);
62
+ const importUrl = `${pathToFileURL(distPath).href}?v=${getPluginImportRevision(id)}`;
63
+ const module = await import(importUrl);
63
64
  const parsed = parsePluginModule(module);
64
65
  if (!parsed) {
65
66
  console.warn(`[plugins] Plugin "${id}" at ${distPath} has no recognizable export.`);
@@ -89,6 +90,11 @@ export async function resolvePlugin(id) {
89
90
  export function invalidatePlugin(id) {
90
91
  clearResolvedPluginEntry(id);
91
92
  }
93
+ /** Invalidate and load a community plugin from disk (e.g. after install/upgrade). */
94
+ export async function reloadPlugin(id) {
95
+ clearResolvedPluginEntry(id);
96
+ return resolvePlugin(id);
97
+ }
92
98
  /** List built-in plugins (for marketplace/registry views). */
93
99
  export function listBuiltInPlugins() {
94
100
  return Object.values(BUILT_IN);
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { exec } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { DEFAULT_PLUGINS_DIR, DEFAULT_MARKETPLACE_REGISTRY_URL, getBaseDir, loadConfig, } from '../../app/config.js';
7
+ import { reloadPlugin } from './registry.js';
7
8
  import { invalidatePlugin } from './plugin-cache.js';
8
9
  const execAsync = promisify(exec);
9
10
  function isLatestVersionAlias(version) {
@@ -190,7 +191,7 @@ export const pluginService = {
190
191
  console.warn(`[plugins] Failed to run npm install in ${finalPath}:`, e);
191
192
  }
192
193
  const pkgJson = JSON.parse(await fs.readFile(path.join(finalPath, 'package.json'), 'utf-8'));
193
- invalidatePlugin(packageName);
194
+ await reloadPlugin(packageName);
194
195
  return { name: pkgJson.name, version: pkgJson.version };
195
196
  }
196
197
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbot",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -11,7 +11,7 @@
11
11
  "build": "tsc && mkdir -p dist/assets && cp src/assets/icon.svg dist/assets/icon.svg",
12
12
  "start": "node dist/app/cli.js start",
13
13
  "format": "prettier --write .",
14
- "push": "fly deploy --build-only --push --config deploy/fly.toml -a openbot-runtime --image-label v0.5.6"
14
+ "push": "fly deploy --build-only --push --config deploy/fly.toml -a openbot-runtime --image-label v0.5.8"
15
15
  },
16
16
  "bin": {
17
17
  "openbot": "./dist/app/cli.js"
package/src/app/cli.ts CHANGED
@@ -27,7 +27,7 @@ function checkNodeVersion() {
27
27
 
28
28
  checkNodeVersion();
29
29
 
30
- program.name('openbot').description('OpenBot CLI').version('0.5.6');
30
+ program.name('openbot').description('OpenBot CLI').version('0.5.8');
31
31
 
32
32
  program
33
33
  .command('start')
@@ -40,11 +40,7 @@ import {
40
40
  import { OpenBotEvent, OpenBotState } from '../../app/types.js';
41
41
  import { processService } from '../../services/process.js';
42
42
  import { memoryService } from '../memory/service.js';
43
- import {
44
- guessMimeType,
45
- resolveChannelFile,
46
- statChannelFile,
47
- } from './files.js';
43
+ import { guessMimeType, resolveChannelFile, statChannelFile } from './files.js';
48
44
 
49
45
  const resolveBaseDir = () => getBaseDir();
50
46
 
@@ -142,10 +138,7 @@ function getCloudSystemDefaultPlugins(): PluginRef[] {
142
138
  });
143
139
  }
144
140
 
145
- function mergeCloudSystemPluginRefs(
146
- defaults: PluginRef[],
147
- diskRefs: PluginRef[],
148
- ): PluginRef[] {
141
+ function mergeCloudSystemPluginRefs(defaults: PluginRef[], diskRefs: PluginRef[]): PluginRef[] {
149
142
  const defaultOpenbot = defaults.find((ref) => ref.id === OPENBOT_PLUGIN_ID);
150
143
  const diskOpenbot = diskRefs.find((ref) => ref.id === OPENBOT_PLUGIN_ID);
151
144
 
@@ -163,10 +156,7 @@ function mergeCloudSystemPluginRefs(
163
156
  }
164
157
 
165
158
  /** No `openbot` / `bash` — storage-side effects and infra plugins only. */
166
- const STATE_DEFAULT_PLUGINS: PluginRef[] = [
167
- { id: 'storage' },
168
- { id: 'plugin-manager' },
169
- ];
159
+ const STATE_DEFAULT_PLUGINS: PluginRef[] = [{ id: 'storage' }, { id: 'plugin-manager' }];
170
160
 
171
161
  const STATE_AGENT_INSTRUCTIONS =
172
162
  'Built-in infra agent for deterministic state reads. No conversational model is attached; handle storage, approvals, memory, and plugin marketplace events.';
@@ -177,8 +167,7 @@ function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails
177
167
  id: SYSTEM_AGENT_ID,
178
168
  name: 'OpenBot',
179
169
  image: getBundledSystemAgentImage(),
180
- description:
181
- 'First-party orchestration agent for OpenBot.',
170
+ description: 'First-party orchestration agent for OpenBot.',
182
171
  instructions: '',
183
172
  plugins: defaultPlugins.map((ref) => ref.id),
184
173
  pluginRefs: defaultPlugins,
@@ -211,9 +200,10 @@ function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails
211
200
  };
212
201
  }
213
202
 
214
- const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
215
- ? overrides.pluginRefs
216
- : defaults.pluginRefs;
203
+ const refs =
204
+ overrides.pluginRefs && overrides.pluginRefs.length > 0
205
+ ? overrides.pluginRefs
206
+ : defaults.pluginRefs;
217
207
 
218
208
  return {
219
209
  ...defaults,
@@ -243,9 +233,10 @@ function getStateAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails {
243
233
 
244
234
  if (!overrides) return defaults;
245
235
 
246
- const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
247
- ? overrides.pluginRefs
248
- : defaults.pluginRefs;
236
+ const refs =
237
+ overrides.pluginRefs && overrides.pluginRefs.length > 0
238
+ ? overrides.pluginRefs
239
+ : defaults.pluginRefs;
249
240
 
250
241
  const diskInstructions = overrides.instructions?.trim();
251
242
  const instructions =
@@ -374,12 +365,42 @@ const toVariablesRecord = (raw: unknown): Record<string, string> => {
374
365
  );
375
366
  };
376
367
 
368
+ let cachedRuntimeVersion: string | undefined;
369
+
370
+ const getRuntimeVersion = (): string | undefined => {
371
+ if (cachedRuntimeVersion !== undefined) return cachedRuntimeVersion || undefined;
372
+ try {
373
+ const pkgPath = path.join(
374
+ path.dirname(fileURLToPath(import.meta.url)),
375
+ '../../../package.json',
376
+ );
377
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: unknown };
378
+ cachedRuntimeVersion = typeof pkg.version === 'string' ? pkg.version : '';
379
+ } catch {
380
+ cachedRuntimeVersion = '';
381
+ }
382
+ return cachedRuntimeVersion || undefined;
383
+ };
384
+
385
+ const readPluginPackageVersion = async (pluginDir: string): Promise<string | undefined> => {
386
+ try {
387
+ const pkg = JSON.parse(await fs.readFile(path.join(pluginDir, 'package.json'), 'utf-8')) as {
388
+ version?: unknown;
389
+ };
390
+ return typeof pkg.version === 'string' ? pkg.version : undefined;
391
+ } catch {
392
+ return undefined;
393
+ }
394
+ };
395
+
377
396
  const listBuiltInPluginDescriptors = async (): Promise<PluginDescriptor[]> => {
397
+ const version = getRuntimeVersion();
378
398
  return listBuiltInPlugins().map((plugin) => ({
379
399
  id: plugin.id,
380
400
  name: plugin.name,
381
401
  description: plugin.description,
382
402
  builtIn: true,
403
+ version,
383
404
  image: plugin.image,
384
405
  configSchema: plugin.configSchema,
385
406
  createdAt: new Date(),
@@ -443,12 +464,16 @@ const listPluginsFromDisk = async (): Promise<PluginDescriptor[]> => {
443
464
  const module = await import(pathToFileURL(distPath).href);
444
465
  const parsed = parsePluginModule(module as Record<string, unknown>);
445
466
  if (!parsed) return null;
446
- const image = await resolveEntityImageDataUrl(pluginDir);
467
+ const [image, version] = await Promise.all([
468
+ resolveEntityImageDataUrl(pluginDir),
469
+ readPluginPackageVersion(pluginDir),
470
+ ]);
447
471
  return {
448
472
  id,
449
473
  name: parsed.name || id,
450
474
  description: parsed.description || '',
451
475
  builtIn: false,
476
+ version,
452
477
  image: parsed.image || image,
453
478
  configSchema: parsed.configSchema,
454
479
  createdAt: new Date(),
@@ -468,9 +493,7 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
468
493
  !!value && typeof value === 'object' && !Array.isArray(value);
469
494
 
470
495
  /** Display-oriented fields persisted in a channel's `state.json`. */
471
- const readChannelStateFileFields = (
472
- parsed: unknown,
473
- ): { name?: string; cwd?: string } => {
496
+ const readChannelStateFileFields = (parsed: unknown): { name?: string; cwd?: string } => {
474
497
  if (!isRecord(parsed)) {
475
498
  return {};
476
499
  }
@@ -583,7 +606,7 @@ export const storageService = {
583
606
  };
584
607
 
585
608
  const channelLastRead = lastReadMap[name] || {};
586
-
609
+
587
610
  try {
588
611
  // Check root unread
589
612
  const rootEvents = await storageService.getEvents({ channelId: name });
@@ -595,8 +618,8 @@ export const storageService = {
595
618
  channel.recentThreads = allThreads
596
619
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
597
620
  .slice(0, 5);
598
-
599
- const threadsUnseen = allThreads.some(t => t.hasUnseenMessages);
621
+
622
+ const threadsUnseen = allThreads.some((t) => t.hasUnseenMessages);
600
623
  channel.hasUnseenMessages = rootUnseen || threadsUnseen;
601
624
  } catch {
602
625
  channel.hasUnseenMessages = false;
@@ -654,11 +677,7 @@ export const storageService = {
654
677
  finalState.cwd = resolvedCwd;
655
678
  await fs.mkdir(resolvedCwd, { recursive: true });
656
679
  await fs.mkdir(channelDir, { recursive: true });
657
- await fs.writeFile(
658
- specPath,
659
- spec?.trim() ||
660
- `# ${normalizedChannelId}\n\n`,
661
- );
680
+ await fs.writeFile(specPath, spec?.trim() || `# ${normalizedChannelId}\n\n`);
662
681
  await writeJsonFileAtomically(statePath, finalState);
663
682
  },
664
683
  ensureChannel: async ({
@@ -708,10 +727,7 @@ export const storageService = {
708
727
  await fs.access(specPath);
709
728
  } catch (error: unknown) {
710
729
  if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
711
- await fs.writeFile(
712
- specPath,
713
- spec?.trim() || `# ${normalizedChannelId}\n\n`,
714
- );
730
+ await fs.writeFile(specPath, spec?.trim() || `# ${normalizedChannelId}\n\n`);
715
731
  } else {
716
732
  throw error;
717
733
  }
@@ -818,8 +834,7 @@ export const storageService = {
818
834
 
819
835
  try {
820
836
  const threadState = await readJsonFile<Record<string, unknown>>(threadStatePath, {});
821
- const threadName =
822
- typeof threadState.name === 'string' ? threadState.name.trim() : '';
837
+ const threadName = typeof threadState.name === 'string' ? threadState.name.trim() : '';
823
838
  if (threadName) {
824
839
  threadDisplayName = threadName;
825
840
  }
@@ -876,10 +891,7 @@ export const storageService = {
876
891
  }
877
892
  }
878
893
 
879
- const threadName =
880
- isRecord(state) && typeof state.name === 'string'
881
- ? state.name.trim()
882
- : '';
894
+ const threadName = isRecord(state) && typeof state.name === 'string' ? state.name.trim() : '';
883
895
 
884
896
  return {
885
897
  id: threadId,
@@ -1078,9 +1090,7 @@ export const storageService = {
1078
1090
 
1079
1091
  const pluginRefs = parsePluginRefs(data.plugins);
1080
1092
  const frontmatterImage =
1081
- typeof data.image === 'string' && data.image.trim() !== ''
1082
- ? data.image.trim()
1083
- : undefined;
1093
+ typeof data.image === 'string' && data.image.trim() !== '' ? data.image.trim() : undefined;
1084
1094
 
1085
1095
  diskDetails = {
1086
1096
  id: agentId,
@@ -1237,8 +1247,7 @@ export const storageService = {
1237
1247
  // updates (e.g. plugins-only) must still persist a complete AGENT.md.
1238
1248
  if (isBuiltinOverlayAgentId(agentId)) {
1239
1249
  const pluginRefs =
1240
- plugins ??
1241
- (nextData.plugins !== undefined ? parsePluginRefs(nextData.plugins) : undefined);
1250
+ plugins ?? (nextData.plugins !== undefined ? parsePluginRefs(nextData.plugins) : undefined);
1242
1251
  const diskOverrides: Partial<AgentDetails> = {};
1243
1252
  if (typeof nextData.name === 'string' && nextData.name.trim() !== '') {
1244
1253
  diskOverrides.name = nextData.name;
@@ -1472,7 +1481,7 @@ export const storageService = {
1472
1481
  'variables' in raw &&
1473
1482
  Array.isArray((raw as { variables: unknown }).variables)
1474
1483
  ) {
1475
- const entries = ((raw as { variables: StoredVariable[] }).variables)
1484
+ const entries = (raw as { variables: StoredVariable[] }).variables
1476
1485
  .filter((v) => typeof v?.key === 'string')
1477
1486
  .map((v) => [v.key, { value: String(v.value ?? ''), secret: !!v.secret }] as const);
1478
1487
  return Object.fromEntries(entries);
@@ -1574,9 +1583,7 @@ export const storageService = {
1574
1583
  throw new Error('Channel has no CWD configured');
1575
1584
  }
1576
1585
 
1577
- const targetDir = subPath
1578
- ? resolveChannelFile(baseCwd, subPath)
1579
- : resolvePath(baseCwd);
1586
+ const targetDir = subPath ? resolveChannelFile(baseCwd, subPath) : resolvePath(baseCwd);
1580
1587
 
1581
1588
  const entries = await fs.readdir(targetDir, { withFileTypes: true });
1582
1589
  return entries
@@ -1623,8 +1630,7 @@ export const storageService = {
1623
1630
 
1624
1631
  const targetFile = resolveChannelFile(baseCwd, filePath);
1625
1632
  const buf = await fs.readFile(targetFile);
1626
- const content =
1627
- encoding === 'base64' ? buf.toString('base64') : buf.toString('utf-8');
1633
+ const content = encoding === 'base64' ? buf.toString('base64') : buf.toString('utf-8');
1628
1634
 
1629
1635
  return {
1630
1636
  content,
@@ -35,6 +35,8 @@ export type PluginDescriptor = {
35
35
  description: string;
36
36
  /** True when bundled with the core server (`src/registry/plugins`); false for ~/.openbot/plugins installs. */
37
37
  builtIn: boolean;
38
+ /** Installed npm semver for disk plugins; runtime package version for built-ins. */
39
+ version?: string;
38
40
  image?: string;
39
41
  configSchema?: ConfigSchema;
40
42
  createdAt: Date;
@@ -136,7 +138,11 @@ export interface Storage {
136
138
  }) => Promise<void>;
137
139
  getThreads: (args: { channelId: string }) => Promise<Thread[]>;
138
140
  getThreadDetails: (args: { channelId: string; threadId: string }) => Promise<ThreadDetails>;
139
- setLastRead: (args: { channelId: string; threadId?: string; lastReadEventId: string }) => Promise<void>;
141
+ setLastRead: (args: {
142
+ channelId: string;
143
+ threadId?: string;
144
+ lastReadEventId: string;
145
+ }) => Promise<void>;
140
146
  /** User-facing agent list; excludes agents with `hidden: true` (e.g. built-in `state`). */
141
147
  getAgents: () => Promise<Agent[]>;
142
148
  getPlugins: () => Promise<PluginDescriptor[]>;
@@ -6,8 +6,16 @@ export const resolvedPluginCache = new Map<string, Plugin>();
6
6
  /** Community plugin ids that have already been logged as loaded once. */
7
7
  export const loadedCommunityPlugins = new Set<string>();
8
8
 
9
+ /** Bumped on invalidate so dynamic imports use a fresh Node ESM cache key. */
10
+ const pluginImportRevisions = new Map<string, number>();
11
+
12
+ export function getPluginImportRevision(id: string): number {
13
+ return pluginImportRevisions.get(id) ?? 0;
14
+ }
15
+
9
16
  /** Drop a single id from the in-memory resolver cache (e.g. after install/uninstall). */
10
17
  export function invalidatePlugin(id: string): void {
11
18
  resolvedPluginCache.delete(id);
12
19
  loadedCommunityPlugins.delete(id);
20
+ pluginImportRevisions.set(id, (pluginImportRevisions.get(id) ?? 0) + 1);
13
21
  }
@@ -7,6 +7,7 @@ import { storagePlugin } from '../../plugins/storage/index.js';
7
7
  import { pluginManagerPlugin } from '../../plugins/plugin-manager/index.js';
8
8
  import { DEFAULT_PLUGINS_DIR, getBaseDir } from '../../app/config.js';
9
9
  import {
10
+ getPluginImportRevision,
10
11
  invalidatePlugin as clearResolvedPluginEntry,
11
12
  loadedCommunityPlugins,
12
13
  resolvedPluginCache,
@@ -27,9 +28,7 @@ const BUILT_IN: Record<string, Plugin> = {
27
28
  export type ParsedPluginModule = Omit<Plugin, 'id'>;
28
29
 
29
30
  /** Normalize a dynamically imported plugin module. Supports `plugin`, `default`. */
30
- export function parsePluginModule(
31
- module: Record<string, unknown>,
32
- ): ParsedPluginModule | null {
31
+ export function parsePluginModule(module: Record<string, unknown>): ParsedPluginModule | null {
33
32
  const raw =
34
33
  (module.plugin as Record<string, unknown> | undefined) ??
35
34
  (module.default as Record<string, unknown> | undefined);
@@ -82,7 +81,8 @@ export async function resolvePlugin(id: string): Promise<Plugin | null> {
82
81
 
83
82
  if (existsSync(distPath)) {
84
83
  try {
85
- const module = await import(pathToFileURL(distPath).href);
84
+ const importUrl = `${pathToFileURL(distPath).href}?v=${getPluginImportRevision(id)}`;
85
+ const module = await import(importUrl);
86
86
  const parsed = parsePluginModule(module as Record<string, unknown>);
87
87
  if (!parsed) {
88
88
  console.warn(`[plugins] Plugin "${id}" at ${distPath} has no recognizable export.`);
@@ -114,6 +114,12 @@ export function invalidatePlugin(id: string): void {
114
114
  clearResolvedPluginEntry(id);
115
115
  }
116
116
 
117
+ /** Invalidate and load a community plugin from disk (e.g. after install/upgrade). */
118
+ export async function reloadPlugin(id: string): Promise<Plugin | null> {
119
+ clearResolvedPluginEntry(id);
120
+ return resolvePlugin(id);
121
+ }
122
+
117
123
  /** List built-in plugins (for marketplace/registry views). */
118
124
  export function listBuiltInPlugins(): Plugin[] {
119
125
  return Object.values(BUILT_IN);
@@ -10,6 +10,7 @@ import {
10
10
  loadConfig,
11
11
  resolvePath,
12
12
  } from '../../app/config.js';
13
+ import { reloadPlugin } from './registry.js';
13
14
  import { invalidatePlugin } from './plugin-cache.js';
14
15
  import { PluginRef } from './types.js';
15
16
 
@@ -273,7 +274,7 @@ export const pluginService = {
273
274
 
274
275
  const pkgJson = JSON.parse(await fs.readFile(path.join(finalPath, 'package.json'), 'utf-8'));
275
276
 
276
- invalidatePlugin(packageName);
277
+ await reloadPlugin(packageName);
277
278
  return { name: pkgJson.name, version: pkgJson.version };
278
279
  } catch (error) {
279
280
  console.error(`[plugins] Failed to install ${packageName}:`, error);