openbot 0.2.13 → 0.3.0

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 (80) hide show
  1. package/dist/agents/openbot/index.js +76 -0
  2. package/dist/agents/openbot/middleware/approval.js +132 -0
  3. package/dist/agents/openbot/runtime.js +289 -0
  4. package/dist/agents/openbot/system-prompt.js +32 -0
  5. package/dist/agents/openbot/tools/delegation.js +78 -0
  6. package/dist/agents/openbot/tools/mcp.js +99 -0
  7. package/dist/agents/openbot/tools/shell.js +91 -0
  8. package/dist/agents/openbot/tools/storage.js +75 -0
  9. package/dist/agents/openbot/tools/ui.js +176 -0
  10. package/dist/agents/system.js +20 -93
  11. package/dist/app/cli.js +1 -1
  12. package/dist/app/config.js +4 -1
  13. package/dist/app/server.js +15 -8
  14. package/dist/bus/agent-package.js +1 -0
  15. package/dist/bus/plugin.js +1 -0
  16. package/dist/bus/services.js +600 -0
  17. package/dist/bus/types.js +1 -0
  18. package/dist/harness/context.js +131 -0
  19. package/dist/harness/event-normalizer.js +59 -0
  20. package/dist/harness/orchestrator.js +27 -227
  21. package/dist/harness/process.js +25 -3
  22. package/dist/harness/queue-processor.js +227 -0
  23. package/dist/harness/runtime-factory.js +103 -0
  24. package/dist/plugins/ai-sdk/index.js +37 -0
  25. package/dist/plugins/ai-sdk/runtime.js +330 -0
  26. package/dist/plugins/ai-sdk/system-prompt.js +3 -0
  27. package/dist/plugins/ai-sdk.js +277 -87
  28. package/dist/plugins/approval/index.js +159 -0
  29. package/dist/plugins/approval.js +163 -0
  30. package/dist/plugins/delegation/index.js +79 -0
  31. package/dist/plugins/delegation.js +67 -11
  32. package/dist/plugins/mcp/index.js +108 -0
  33. package/dist/plugins/shell/index.js +99 -0
  34. package/dist/plugins/shell.js +123 -0
  35. package/dist/plugins/storage-tools/index.js +85 -0
  36. package/dist/plugins/storage.js +240 -5
  37. package/dist/plugins/ui/index.js +184 -0
  38. package/dist/plugins/ui.js +185 -21
  39. package/dist/registry/agents.js +138 -0
  40. package/dist/registry/plugins.js +91 -50
  41. package/dist/services/agent-packages.js +103 -0
  42. package/dist/services/plugins.js +98 -0
  43. package/dist/services/storage.js +360 -94
  44. package/docs/agents.md +39 -66
  45. package/docs/architecture.md +1 -1
  46. package/docs/plugins.md +70 -58
  47. package/docs/templates/AGENT.example.md +57 -0
  48. package/package.json +3 -2
  49. package/src/app/cli.ts +1 -1
  50. package/src/app/config.ts +14 -4
  51. package/src/app/server.ts +23 -10
  52. package/src/app/types.ts +385 -16
  53. package/src/assets/icon.svg +4 -1
  54. package/src/bus/plugin.ts +67 -0
  55. package/src/bus/services.ts +666 -0
  56. package/src/bus/types.ts +147 -0
  57. package/src/harness/context.ts +160 -0
  58. package/src/harness/event-normalizer.ts +82 -0
  59. package/src/harness/orchestrator.ts +35 -273
  60. package/src/harness/process.ts +28 -4
  61. package/src/harness/queue-processor.ts +309 -0
  62. package/src/harness/runtime-factory.ts +125 -0
  63. package/src/plugins/ai-sdk/index.ts +44 -0
  64. package/src/plugins/ai-sdk/runtime.ts +410 -0
  65. package/src/plugins/ai-sdk/system-prompt.ts +4 -0
  66. package/src/plugins/approval/index.ts +228 -0
  67. package/src/plugins/delegation/index.ts +94 -0
  68. package/src/plugins/mcp/index.ts +128 -0
  69. package/src/plugins/shell/index.ts +123 -0
  70. package/src/plugins/storage-tools/index.ts +101 -0
  71. package/src/plugins/ui/index.ts +227 -0
  72. package/src/registry/plugins.ts +106 -55
  73. package/src/services/plugins.ts +133 -0
  74. package/src/services/storage.ts +465 -137
  75. package/src/agents/system.ts +0 -112
  76. package/src/plugins/ai-sdk.ts +0 -197
  77. package/src/plugins/delegation.ts +0 -60
  78. package/src/plugins/mcp.ts +0 -154
  79. package/src/plugins/storage.ts +0 -725
  80. package/src/plugins/ui.ts +0 -57
@@ -1,19 +1,13 @@
1
- import { DEFAULT_AGENTS_DIR, DEFAULT_BASE_DIR, DEFAULT_CHANNELS_DIR, DEFAULT_PLUGINS_DIR, loadConfig, resolvePath, VARIABLES_FILE, } from '../app/config.js';
1
+ import { DEFAULT_PLUGINS_DIR, DEFAULT_AGENTS_DIR, DEFAULT_BASE_DIR, DEFAULT_CHANNELS_DIR, loadConfig, resolvePath, VARIABLES_FILE, } from '../app/config.js';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
5
  import matter from 'gray-matter';
6
- import { getSystemAgentDetails } from '../agents/system.js';
6
+ import { aiSdkPlugin } from '../plugins/ai-sdk/index.js';
7
+ import { AI_SDK_SYSTEM_PROMPT } from '../plugins/ai-sdk/system-prompt.js';
8
+ import { listBuiltInPlugins, parsePluginModule } from '../registry/plugins.js';
9
+ import { processService } from '../harness/process.js';
7
10
  import { pathToFileURL } from 'node:url';
8
- const mapNameToPlugin = (name, description, kind = 'tool', image) => ({
9
- id: name,
10
- name,
11
- description,
12
- kind,
13
- image,
14
- createdAt: new Date(),
15
- updatedAt: new Date(),
16
- });
17
11
  const resolveBaseDir = () => {
18
12
  const config = loadConfig();
19
13
  return resolvePath(config.baseDir || DEFAULT_BASE_DIR);
@@ -32,15 +26,6 @@ const tryReadSvgDataUrl = async (filePath) => {
32
26
  return null;
33
27
  }
34
28
  };
35
- /**
36
- * Auto-discovers an entity SVG avatar and returns it as a data URL.
37
- *
38
- * Search order:
39
- * 1) <entity>/assets/avatar.svg|icon.svg|image.svg|logo.svg
40
- * 2) <entity>/avatar.svg|icon.svg|image.svg|logo.svg
41
- * 3) first *.svg in <entity>/assets
42
- * 4) first *.svg in <entity>
43
- */
44
29
  const resolveEntityImageDataUrl = async (entityDir) => {
45
30
  const preferredDirs = [path.join(entityDir, 'assets'), entityDir];
46
31
  for (const dir of preferredDirs) {
@@ -61,7 +46,7 @@ const resolveEntityImageDataUrl = async (entityDir) => {
61
46
  return dataUrl;
62
47
  }
63
48
  catch {
64
- // ignore missing/unreadable folders
49
+ // ignore
65
50
  }
66
51
  }
67
52
  return undefined;
@@ -70,6 +55,59 @@ const getConversationDir = (channelId, threadId) => {
70
55
  const base = resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR + '/' + channelId);
71
56
  return threadId ? `${base}/threads/${threadId}` : base;
72
57
  };
58
+ /** Built-in orchestrator agent id. Not creatable as a normal disk agent. */
59
+ const SYSTEM_AGENT_ID = 'system';
60
+ const SYSTEM_DEFAULT_PLUGINS = [
61
+ { id: 'ai-sdk', config: { model: 'openai/gpt-5.4-nano' } },
62
+ { id: 'storage-tools' },
63
+ // { id: 'mcp' },
64
+ { id: 'shell' },
65
+ { id: 'delegation' },
66
+ // { id: 'ui' },
67
+ { id: 'approval' },
68
+ ];
69
+ function getSystemAgentDetails(overrides) {
70
+ const defaults = {
71
+ id: SYSTEM_AGENT_ID,
72
+ name: 'OpenBot',
73
+ image: undefined,
74
+ description: 'First-party orchestration agent for OpenBot. Coordinates other agents via handoff and delegation.',
75
+ instructions: AI_SDK_SYSTEM_PROMPT,
76
+ plugins: SYSTEM_DEFAULT_PLUGINS.map((ref) => ref.id),
77
+ pluginRefs: SYSTEM_DEFAULT_PLUGINS,
78
+ createdAt: new Date(),
79
+ updatedAt: new Date(),
80
+ };
81
+ if (!overrides)
82
+ return defaults;
83
+ const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
84
+ ? overrides.pluginRefs
85
+ : defaults.pluginRefs;
86
+ return {
87
+ ...defaults,
88
+ ...overrides,
89
+ id: SYSTEM_AGENT_ID,
90
+ image: overrides.image || defaults.image,
91
+ plugins: refs.map((ref) => ref.id),
92
+ pluginRefs: refs,
93
+ updatedAt: new Date(),
94
+ };
95
+ }
96
+ // Suppress unused warning until system agent customization re-uses aiSdkPlugin metadata.
97
+ void aiSdkPlugin;
98
+ const RESERVED_DISK_AGENT_IDS = new Set([SYSTEM_AGENT_ID]);
99
+ const assertValidDiskAgentId = (agentId) => {
100
+ if (!agentId || typeof agentId !== 'string') {
101
+ throw new Error('agentId is required');
102
+ }
103
+ if (RESERVED_DISK_AGENT_IDS.has(agentId)) {
104
+ throw new Error(`Agent id "${agentId}" is reserved`);
105
+ }
106
+ if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) {
107
+ throw new Error('agentId must contain only letters, digits, underscores, and hyphens');
108
+ }
109
+ };
110
+ const getAgentsRootDir = () => path.join(resolveBaseDir(), DEFAULT_AGENTS_DIR);
73
111
  const getLastReadFilePath = () => path.join(resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR), '_meta', 'last-read.json');
74
112
  const THREAD_TITLE_MAX_LENGTH = 80;
75
113
  const buildThreadTitleFromEvent = (event) => {
@@ -104,25 +142,67 @@ const toVariablesRecord = (raw) => {
104
142
  if (!raw || typeof raw !== 'object') {
105
143
  return {};
106
144
  }
107
- // Current format: { version: number, variables: StoredVariable[] }
108
145
  if ('variables' in raw && Array.isArray(raw.variables)) {
109
146
  const entries = raw.variables
110
147
  .filter((variable) => typeof variable?.key === 'string')
111
148
  .map((variable) => [variable.key, String(variable.value ?? '')]);
112
149
  return Object.fromEntries(entries);
113
150
  }
114
- // Legacy format: { [key: string]: string }
115
151
  return Object.fromEntries(Object.entries(raw).map(([key, value]) => [
116
152
  key,
117
153
  String(value ?? ''),
118
154
  ]));
119
155
  };
120
- const listBuiltInPlugins = async () => {
121
- return [
122
- mapNameToPlugin('storage', 'Built-in storage plugin'),
123
- mapNameToPlugin('ai-sdk', 'Built-in AI SDK plugin', 'runtime'),
124
- mapNameToPlugin('delegation', 'Built-in delegation plugin'),
125
- ];
156
+ const listBuiltInPluginDescriptors = async () => {
157
+ return listBuiltInPlugins().map((plugin) => ({
158
+ id: plugin.id,
159
+ name: plugin.name,
160
+ description: plugin.description,
161
+ builtIn: true,
162
+ image: plugin.image,
163
+ defaultInstructions: plugin.defaultInstructions,
164
+ configSchema: plugin.configSchema,
165
+ createdAt: new Date(),
166
+ updatedAt: new Date(),
167
+ }));
168
+ };
169
+ /**
170
+ * Walk `plugins/` and yield candidate plugin ids (npm names). Includes scoped
171
+ * packages by recursing one level into directories starting with `@`.
172
+ */
173
+ const listInstalledPluginIds = async (pluginsDir) => {
174
+ const out = [];
175
+ let topEntries;
176
+ try {
177
+ topEntries = await fs.readdir(pluginsDir, { withFileTypes: true });
178
+ }
179
+ catch {
180
+ return out;
181
+ }
182
+ for (const entry of topEntries) {
183
+ if (entry.name.startsWith('.'))
184
+ continue;
185
+ if (!entry.isDirectory() && !entry.isSymbolicLink())
186
+ continue;
187
+ if (entry.name.startsWith('@')) {
188
+ try {
189
+ const inner = await fs.readdir(path.join(pluginsDir, entry.name), { withFileTypes: true });
190
+ for (const sub of inner) {
191
+ if (sub.name.startsWith('.'))
192
+ continue;
193
+ if (sub.isDirectory() || sub.isSymbolicLink()) {
194
+ out.push(`${entry.name}/${sub.name}`);
195
+ }
196
+ }
197
+ }
198
+ catch {
199
+ // ignore
200
+ }
201
+ continue;
202
+ }
203
+ out.push(entry.name);
204
+ }
205
+ return out;
126
206
  };
127
207
  const listPluginsFromDisk = async () => {
128
208
  const pluginsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_PLUGINS_DIR);
@@ -132,17 +212,57 @@ const listPluginsFromDisk = async () => {
132
212
  catch {
133
213
  await fs.mkdir(pluginsDir, { recursive: true });
134
214
  }
135
- const plugins = (await fs.readdir(pluginsDir, { withFileTypes: true }))
136
- .filter((entry) => !entry.name.startsWith('.') && (entry.isDirectory() || entry.isSymbolicLink()))
137
- .map(async (entry) => {
138
- // get dist/index module and find inside module.plugin.description
139
- const module = await import(pathToFileURL(`${pluginsDir}/${entry.name}/dist/index.js`).href);
140
- const pluginDir = path.join(pluginsDir, entry.name);
141
- const image = await resolveEntityImageDataUrl(pluginDir);
142
- return mapNameToPlugin(module.plugin.name || entry.name, module.plugin.description || '', module.plugin.kind || 'tool', image);
143
- });
144
- return Promise.all(plugins);
215
+ const ids = await listInstalledPluginIds(pluginsDir);
216
+ const descriptors = await Promise.all(ids.map(async (id) => {
217
+ try {
218
+ const pluginDir = path.join(pluginsDir, id);
219
+ const distPath = path.join(pluginDir, 'dist', 'index.js');
220
+ const module = await import(pathToFileURL(distPath).href);
221
+ const parsed = parsePluginModule(module);
222
+ if (!parsed)
223
+ return null;
224
+ const image = await resolveEntityImageDataUrl(pluginDir);
225
+ return {
226
+ id,
227
+ name: parsed.name || id,
228
+ description: parsed.description || '',
229
+ builtIn: false,
230
+ image: parsed.image || image,
231
+ defaultInstructions: parsed.defaultInstructions,
232
+ configSchema: parsed.configSchema,
233
+ createdAt: new Date(),
234
+ updatedAt: new Date(),
235
+ };
236
+ }
237
+ catch (error) {
238
+ console.warn(`[storage] Failed to load plugin ${id}:`, error);
239
+ return null;
240
+ }
241
+ }));
242
+ return descriptors.filter((d) => d !== null);
243
+ };
244
+ const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
245
+ /**
246
+ * Parse the `plugins:` array from AGENT.md frontmatter. Each entry must have an
247
+ * `id`; `config` is optional. Strings are accepted as a shorthand for `{ id }`.
248
+ */
249
+ const parsePluginRefs = (raw) => {
250
+ if (!Array.isArray(raw))
251
+ return [];
252
+ const refs = [];
253
+ for (const entry of raw) {
254
+ if (typeof entry === 'string' && entry.trim()) {
255
+ refs.push({ id: entry.trim() });
256
+ continue;
257
+ }
258
+ if (isRecord(entry) && typeof entry.id === 'string' && entry.id.trim()) {
259
+ const config = isRecord(entry.config) ? entry.config : undefined;
260
+ refs.push({ id: entry.id.trim(), ...(config ? { config } : {}) });
261
+ }
262
+ }
263
+ return refs;
145
264
  };
265
+ const serializePluginRefs = (refs) => refs.map((ref) => (ref.config ? { id: ref.id, config: ref.config } : { id: ref.id }));
146
266
  export const storageService = {
147
267
  getLastReadByChannel: async () => {
148
268
  return readJsonFile(getLastReadFilePath(), {});
@@ -174,7 +294,7 @@ export const storageService = {
174
294
  cwd = typeof state.cwd === 'string' ? state.cwd : undefined;
175
295
  }
176
296
  catch {
177
- // Ignore if state.json is missing or invalid
297
+ // ignore
178
298
  }
179
299
  const channel = {
180
300
  id: name,
@@ -194,7 +314,6 @@ export const storageService = {
194
314
  channel.hasUnseenMessages = false;
195
315
  }
196
316
  try {
197
- // Fetch up to 5 most recent threads for the sidebar
198
317
  const allThreads = await storageService.getThreads({ channelId: name });
199
318
  channel.recentThreads = allThreads
200
319
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
@@ -220,7 +339,8 @@ export const storageService = {
220
339
  throw new Error(`Channel "${normalizedChannelId}" already exists`);
221
340
  }
222
341
  catch (error) {
223
- if (error.code !== 'ENOENT') {
342
+ const code = error?.code;
343
+ if (code !== 'ENOENT') {
224
344
  throw error;
225
345
  }
226
346
  }
@@ -238,12 +358,10 @@ export const storageService = {
238
358
  createThread: async ({ channelId, threadId, threadTitle, spec, initialState, }) => {
239
359
  const normalizedChannelId = channelId.trim();
240
360
  const normalizedThreadId = threadId.trim();
241
- if (!normalizedChannelId) {
361
+ if (!normalizedChannelId)
242
362
  throw new Error('channelId is required');
243
- }
244
- if (!normalizedThreadId) {
363
+ if (!normalizedThreadId)
245
364
  throw new Error('threadId is required');
246
- }
247
365
  const threadDir = getConversationDir(normalizedChannelId, normalizedThreadId);
248
366
  const specPath = `${threadDir}/SPEC.md`;
249
367
  const statePath = `${threadDir}/state.json`;
@@ -252,7 +370,8 @@ export const storageService = {
252
370
  throw new Error(`Thread "${normalizedThreadId}" already exists in channel "${normalizedChannelId}"`);
253
371
  }
254
372
  catch (error) {
255
- if (error.code !== 'ENOENT') {
373
+ const code = error?.code;
374
+ if (code !== 'ENOENT') {
256
375
  throw error;
257
376
  }
258
377
  }
@@ -288,7 +407,7 @@ export const storageService = {
288
407
  }
289
408
  }
290
409
  catch (error) {
291
- if (error.code !== 'ENOENT') {
410
+ if (error?.code !== 'ENOENT') {
292
411
  console.error(`Failed to read thread state for channel ${channelId} thread ${name}`, error);
293
412
  }
294
413
  }
@@ -311,7 +430,7 @@ export const storageService = {
311
430
  spec = await fs.readFile(specPath, 'utf-8');
312
431
  }
313
432
  catch (error) {
314
- if (error.code !== 'ENOENT') {
433
+ if (error?.code !== 'ENOENT') {
315
434
  console.error(`Failed to read thread spec for channel ${channelId} thread ${threadId}`, error);
316
435
  }
317
436
  }
@@ -321,11 +440,11 @@ export const storageService = {
321
440
  state = JSON.parse(stateContent);
322
441
  }
323
442
  catch (error) {
324
- if (error.code !== 'ENOENT') {
443
+ if (error?.code !== 'ENOENT') {
325
444
  console.error(`Failed to read thread state for channel ${channelId} thread ${threadId}`, error);
326
445
  }
327
446
  }
328
- const generatedName = typeof state.generatedName === 'string'
447
+ const generatedName = isRecord(state) && typeof state.generatedName === 'string'
329
448
  ? state.generatedName.trim()
330
449
  : '';
331
450
  return {
@@ -345,7 +464,7 @@ export const storageService = {
345
464
  spec = await fs.readFile(specPath, 'utf-8');
346
465
  }
347
466
  catch (error) {
348
- if (error.code !== 'ENOENT') {
467
+ if (error?.code !== 'ENOENT') {
349
468
  console.error(`Failed to read spec file for channel ${channelId}`, error);
350
469
  }
351
470
  }
@@ -355,16 +474,17 @@ export const storageService = {
355
474
  state = JSON.parse(stateContent);
356
475
  }
357
476
  catch (error) {
358
- if (error.code !== 'ENOENT') {
477
+ if (error?.code !== 'ENOENT') {
359
478
  console.error(`Failed to read state file for channel ${channelId}`, error);
360
479
  }
361
480
  }
481
+ const cwd = isRecord(state) && typeof state.cwd === 'string' ? state.cwd : undefined;
362
482
  const details = {
363
483
  id: channelId,
364
484
  name: channelId,
365
485
  spec,
366
486
  state,
367
- cwd: typeof state.cwd === 'string' ? state.cwd : undefined,
487
+ cwd,
368
488
  };
369
489
  details.threads = await storageService.getThreads({ channelId });
370
490
  return details;
@@ -373,15 +493,12 @@ export const storageService = {
373
493
  const channelDir = getConversationDir(channelId);
374
494
  const statePath = `${channelDir}/state.json`;
375
495
  try {
376
- // 1. Fetch current details to get the existing state
377
496
  const currentDetails = await storageService.getChannelDetails({ channelId });
378
497
  const currentState = currentDetails.state || {};
379
- // 2. Perform a shallow merge (patch)
380
498
  const newState = {
381
499
  ...currentState,
382
500
  ...patch,
383
501
  };
384
- // 3. Write back the merged state
385
502
  await fs.mkdir(channelDir, { recursive: true });
386
503
  await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
387
504
  }
@@ -394,15 +511,12 @@ export const storageService = {
394
511
  const threadDir = getConversationDir(channelId, threadId);
395
512
  const statePath = `${threadDir}/state.json`;
396
513
  try {
397
- // 1. Fetch current details to get the existing state
398
514
  const currentDetails = await storageService.getThreadDetails({ channelId, threadId });
399
515
  const currentState = currentDetails.state || {};
400
- // 2. Perform a shallow merge (patch)
401
516
  const newState = {
402
517
  ...currentState,
403
518
  ...patch,
404
519
  };
405
- // 3. Write back the merged state
406
520
  await fs.mkdir(threadDir, { recursive: true });
407
521
  await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
408
522
  }
@@ -452,7 +566,7 @@ export const storageService = {
452
566
  name: details.name || id,
453
567
  description: details.description || '',
454
568
  image: details.image,
455
- runtime: details.runtime,
569
+ plugins: details.plugins,
456
570
  createdAt: details.createdAt,
457
571
  updatedAt: details.updatedAt,
458
572
  };
@@ -462,18 +576,19 @@ export const storageService = {
462
576
  id,
463
577
  name: id,
464
578
  description: '',
579
+ plugins: [],
465
580
  createdAt: new Date(),
466
581
  updatedAt: new Date(),
467
582
  };
468
583
  }
469
584
  }));
470
- const system = await storageService.getAgentDetails({ agentId: 'system' });
585
+ const system = await storageService.getAgentDetails({ agentId: SYSTEM_AGENT_ID });
471
586
  const builtInSystemAgent = {
472
587
  id: system.id,
473
588
  name: system.name,
474
589
  description: system.description || '',
475
590
  image: system.image,
476
- runtime: system.runtime,
591
+ plugins: system.plugins,
477
592
  createdAt: system.createdAt,
478
593
  updatedAt: system.updatedAt,
479
594
  };
@@ -486,11 +601,11 @@ export const storageService = {
486
601
  return Array.from(deduped.values());
487
602
  },
488
603
  getPlugins: async () => {
489
- const [builtInPlugins, diskPlugins] = await Promise.all([
490
- listBuiltInPlugins(),
604
+ const [builtIn, fromDisk] = await Promise.all([
605
+ listBuiltInPluginDescriptors(),
491
606
  listPluginsFromDisk(),
492
607
  ]);
493
- const merged = [...builtInPlugins, ...diskPlugins];
608
+ const merged = [...builtIn, ...fromDisk];
494
609
  const deduped = new Map();
495
610
  for (const plugin of merged) {
496
611
  if (!deduped.has(plugin.id)) {
@@ -508,26 +623,30 @@ export const storageService = {
508
623
  const agentMd = await fs.readFile(agentMdPath, 'utf-8');
509
624
  const { data, content: instructions } = matter(agentMd);
510
625
  const discoveredImage = await resolveEntityImageDataUrl(agentDir);
626
+ const stats = await fs.stat(agentMdPath);
627
+ const pluginRefs = parsePluginRefs(data.plugins);
511
628
  diskDetails = {
512
629
  id: agentId,
513
- name: data.name || agentId,
630
+ name: typeof data.name === 'string' ? data.name : agentId,
514
631
  instructions: instructions.trim(),
515
- runtime: data.runtime,
516
- plugins: data.plugins || [],
517
- description: data.description || '',
632
+ plugins: pluginRefs.map((ref) => ref.id),
633
+ pluginRefs,
634
+ description: typeof data.description === 'string' ? data.description : '',
518
635
  image: discoveredImage || undefined,
519
- createdAt: new Date(),
520
- updatedAt: new Date(),
636
+ createdAt: stats.birthtime,
637
+ updatedAt: stats.mtime,
521
638
  };
522
639
  }
523
640
  catch (error) {
524
- if (agentId !== 'system') {
641
+ if (agentId !== SYSTEM_AGENT_ID) {
525
642
  const err = new Error(`Agent "${agentId}" does not exist.`);
526
643
  err.code = 'AGENT_NOT_FOUND';
527
644
  throw err;
528
645
  }
646
+ // swallow: system agent has on-disk overrides optional
647
+ void error;
529
648
  }
530
- if (agentId === 'system') {
649
+ if (agentId === SYSTEM_AGENT_ID) {
531
650
  return getSystemAgentDetails(diskDetails);
532
651
  }
533
652
  if (!diskDetails) {
@@ -537,6 +656,103 @@ export const storageService = {
537
656
  }
538
657
  return diskDetails;
539
658
  },
659
+ createAgent: async ({ agentId, name, description = '', instructions, plugins, }) => {
660
+ assertValidDiskAgentId(agentId);
661
+ const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
662
+ const agentMdPath = path.join(agentDir, 'AGENT.md');
663
+ try {
664
+ await fs.access(agentMdPath);
665
+ throw new Error(`Agent "${agentId}" already exists`);
666
+ }
667
+ catch (error) {
668
+ const code = error?.code;
669
+ if (code === 'ENOENT') {
670
+ // proceed
671
+ }
672
+ else if (error instanceof Error && error.message.includes('already exists')) {
673
+ throw error;
674
+ }
675
+ else {
676
+ throw error;
677
+ }
678
+ }
679
+ await fs.mkdir(agentDir, { recursive: true });
680
+ const data = {
681
+ name,
682
+ description,
683
+ plugins: serializePluginRefs(plugins),
684
+ };
685
+ const body = matter.stringify(`${instructions.trim()}\n`, data);
686
+ await fs.writeFile(agentMdPath, body, 'utf-8');
687
+ },
688
+ updateAgent: async ({ agentId, name, description, instructions, plugins, }) => {
689
+ assertValidDiskAgentId(agentId);
690
+ const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
691
+ const agentMdPath = path.join(agentDir, 'AGENT.md');
692
+ let raw;
693
+ try {
694
+ raw = await fs.readFile(agentMdPath, 'utf-8');
695
+ }
696
+ catch (error) {
697
+ if (error?.code === 'ENOENT') {
698
+ const err = new Error(`Agent "${agentId}" does not exist.`);
699
+ err.code = 'AGENT_NOT_FOUND';
700
+ throw err;
701
+ }
702
+ throw error;
703
+ }
704
+ const parsed = matter(raw);
705
+ const nextData = { ...parsed.data };
706
+ if (name !== undefined)
707
+ nextData.name = name;
708
+ if (description !== undefined)
709
+ nextData.description = description;
710
+ if (plugins !== undefined)
711
+ nextData.plugins = serializePluginRefs(plugins);
712
+ const nextContent = instructions !== undefined ? instructions : parsed.content;
713
+ const body = matter.stringify(`${String(nextContent).trim()}\n`, nextData);
714
+ await fs.writeFile(agentMdPath, body, 'utf-8');
715
+ },
716
+ deleteAgent: async ({ agentId }) => {
717
+ assertValidDiskAgentId(agentId);
718
+ const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
719
+ const agentMdPath = path.join(agentDir, 'AGENT.md');
720
+ const packageJsonPath = path.join(agentDir, 'package.json');
721
+ try {
722
+ await fs.access(agentDir);
723
+ }
724
+ catch (error) {
725
+ if (error?.code === 'ENOENT') {
726
+ const err = new Error(`Agent "${agentId}" does not exist.`);
727
+ err.code = 'AGENT_NOT_FOUND';
728
+ throw err;
729
+ }
730
+ throw error;
731
+ }
732
+ let hasPackage = false;
733
+ let hasAgentMd = false;
734
+ try {
735
+ await fs.access(packageJsonPath);
736
+ hasPackage = true;
737
+ }
738
+ catch {
739
+ // ignore
740
+ }
741
+ try {
742
+ await fs.access(agentMdPath);
743
+ hasAgentMd = true;
744
+ }
745
+ catch {
746
+ // ignore
747
+ }
748
+ if (hasPackage && !hasAgentMd) {
749
+ throw new Error(`Cannot delete TypeScript agent package "${agentId}" through this action; remove the folder manually.`);
750
+ }
751
+ if (!hasAgentMd) {
752
+ throw new Error(`Agent "${agentId}" has no AGENT.md and cannot be deleted through this action.`);
753
+ }
754
+ await fs.rm(agentDir, { recursive: true, force: true });
755
+ },
540
756
  getEvents: async ({ channelId, threadId, }) => {
541
757
  try {
542
758
  const threadDir = getConversationDir(channelId, threadId);
@@ -554,22 +770,18 @@ export const storageService = {
554
770
  }
555
771
  return event;
556
772
  });
557
- // If we are at the channel level (no threadId), check which events have threads
558
773
  if (!threadId) {
559
774
  const threadsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR + '/' + channelId + '/threads');
560
775
  try {
561
776
  const threadDirs = await fs.readdir(threadsDir);
562
777
  const threadSet = new Set(threadDirs);
563
778
  return events.map((event) => {
564
- // Check if this event has a threadId associated with it
565
- // The frontend provides the threadId, and it matches the directory name on disk
566
- const threadId = event.id;
567
- // If an explicit threadId exists and has a directory, use it
568
- if (threadId && threadSet.has(threadId)) {
779
+ const eventThreadId = event.id;
780
+ if (eventThreadId && threadSet.has(eventThreadId)) {
569
781
  return {
570
782
  ...event,
571
783
  meta: {
572
- ...event?.meta,
784
+ ...(event.meta || {}),
573
785
  hasThread: true,
574
786
  },
575
787
  };
@@ -578,14 +790,13 @@ export const storageService = {
578
790
  });
579
791
  }
580
792
  catch {
581
- // No threads folder or other error, just return events as is
582
793
  return events;
583
794
  }
584
795
  }
585
796
  return events;
586
797
  }
587
798
  catch (error) {
588
- if (error.code !== 'ENOENT') {
799
+ if (error?.code !== 'ENOENT') {
589
800
  console.error(`Failed to get events for channel ${channelId} thread ${threadId}`, error);
590
801
  }
591
802
  return [];
@@ -599,7 +810,7 @@ export const storageService = {
599
810
  await fs.access(threadDir);
600
811
  }
601
812
  catch (error) {
602
- if (error.code === 'ENOENT') {
813
+ if (error?.code === 'ENOENT') {
603
814
  const threadTitle = buildThreadTitleFromEvent(event);
604
815
  await storageService.createThread({
605
816
  channelId,
@@ -615,7 +826,6 @@ export const storageService = {
615
826
  else {
616
827
  await fs.mkdir(threadDir, { recursive: true });
617
828
  }
618
- // Ensure the event has a unique ID
619
829
  if (!event.id) {
620
830
  event.id = crypto.randomUUID();
621
831
  }
@@ -629,15 +839,70 @@ export const storageService = {
629
839
  getVariables: async () => {
630
840
  const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
631
841
  const raw = await readJsonFile(variablesFilePath, {});
632
- if (raw && typeof raw === 'object' && 'variables' in raw && Array.isArray(raw.variables)) {
633
- const entries = raw.variables
842
+ if (raw &&
843
+ typeof raw === 'object' &&
844
+ 'variables' in raw &&
845
+ Array.isArray(raw.variables)) {
846
+ const entries = (raw.variables)
634
847
  .filter((v) => typeof v?.key === 'string')
635
848
  .map((v) => [v.key, { value: String(v.value ?? ''), secret: !!v.secret }]);
636
849
  return Object.fromEntries(entries);
637
850
  }
638
- // Legacy or simple format
639
851
  return toVariablesRecord(raw);
640
852
  },
853
+ createVariable: async ({ key, value, secret = false, }) => {
854
+ const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
855
+ const raw = await readJsonFile(variablesFilePath, { version: 1, variables: [] });
856
+ let variables = [];
857
+ if (raw &&
858
+ typeof raw === 'object' &&
859
+ 'variables' in raw &&
860
+ Array.isArray(raw.variables)) {
861
+ variables = raw.variables;
862
+ }
863
+ else {
864
+ variables = Object.entries(toVariablesRecord(raw)).map(([k, v]) => ({
865
+ key: k,
866
+ value: v,
867
+ secret: false,
868
+ }));
869
+ }
870
+ const existingIndex = variables.findIndex((v) => v.key === key);
871
+ if (existingIndex !== -1) {
872
+ variables[existingIndex] = { key, value, secret };
873
+ }
874
+ else {
875
+ variables.push({ key, value, secret });
876
+ }
877
+ await fs.mkdir(path.dirname(variablesFilePath), { recursive: true });
878
+ await fs.writeFile(variablesFilePath, JSON.stringify({ version: 1, variables }, null, 2), 'utf-8');
879
+ processService.syncWorkspaceVariablesToProcessEnv();
880
+ },
881
+ deleteVariable: async ({ key }) => {
882
+ const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
883
+ const raw = await readJsonFile(variablesFilePath, { version: 1, variables: [] });
884
+ let variables = [];
885
+ if (raw &&
886
+ typeof raw === 'object' &&
887
+ 'variables' in raw &&
888
+ Array.isArray(raw.variables)) {
889
+ variables = raw.variables;
890
+ }
891
+ else {
892
+ variables = Object.entries(toVariablesRecord(raw)).map(([k, v]) => ({
893
+ key: k,
894
+ value: v,
895
+ secret: false,
896
+ }));
897
+ }
898
+ const newVariables = variables.filter((v) => v.key !== key);
899
+ if (newVariables.length === variables.length) {
900
+ return;
901
+ }
902
+ await fs.mkdir(path.dirname(variablesFilePath), { recursive: true });
903
+ await fs.writeFile(variablesFilePath, JSON.stringify({ version: 1, variables: newVariables }, null, 2), 'utf-8');
904
+ processService.syncWorkspaceVariablesToProcessEnv();
905
+ },
641
906
  listFiles: async ({ channelId, path: subPath = '', }) => {
642
907
  const details = await storageService.getChannelDetails({ channelId });
643
908
  const baseCwd = details.cwd;
@@ -646,19 +911,18 @@ export const storageService = {
646
911
  }
647
912
  const resolvedBase = path.resolve(baseCwd);
648
913
  const targetDir = path.resolve(resolvedBase, subPath);
649
- // Security check: ensure target is within baseCwd
650
914
  if (!targetDir.startsWith(resolvedBase)) {
651
915
  throw new Error('Access denied: directory escape');
652
916
  }
653
917
  const entries = await fs.readdir(targetDir, { withFileTypes: true });
654
918
  return entries
655
- .filter((e) => !e.name.startsWith('.')) // Hide hidden files by default for MVP
919
+ .filter((e) => !e.name.startsWith('.'))
656
920
  .map((e) => ({
657
921
  name: e.name,
658
922
  isDirectory: e.isDirectory(),
659
923
  }));
660
924
  },
661
- readFile: async ({ channelId, path: filePath }) => {
925
+ readFile: async ({ channelId, path: filePath, }) => {
662
926
  const details = await storageService.getChannelDetails({ channelId });
663
927
  const baseCwd = details.cwd;
664
928
  if (!baseCwd) {
@@ -666,7 +930,6 @@ export const storageService = {
666
930
  }
667
931
  const resolvedBase = path.resolve(baseCwd);
668
932
  const targetFile = path.resolve(resolvedBase, filePath);
669
- // Security check: ensure target is within baseCwd
670
933
  if (!targetFile.startsWith(resolvedBase)) {
671
934
  throw new Error('Access denied: directory escape');
672
935
  }
@@ -686,7 +949,7 @@ export const storageService = {
686
949
  throw error;
687
950
  }
688
951
  let channelDetails;
689
- if (channelId && channelId !== 'general') {
952
+ if (channelId) {
690
953
  try {
691
954
  channelDetails = await storageService.getChannelDetails({ channelId });
692
955
  }
@@ -713,9 +976,12 @@ export const storageService = {
713
976
  id: agentDetails.id,
714
977
  name: agentDetails.name,
715
978
  description: agentDetails.description || '',
979
+ image: agentDetails.image,
716
980
  instructions: agentDetails.instructions || '',
717
- runtime: agentDetails.runtime,
718
981
  plugins: agentDetails.plugins,
982
+ pluginRefs: agentDetails.pluginRefs,
983
+ createdAt: agentDetails.createdAt,
984
+ updatedAt: agentDetails.updatedAt,
719
985
  },
720
986
  channelDetails: channelDetails,
721
987
  threadDetails: threadDetails,