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,8 +1,8 @@
1
1
  import {
2
+ DEFAULT_PLUGINS_DIR,
2
3
  DEFAULT_AGENTS_DIR,
3
4
  DEFAULT_BASE_DIR,
4
5
  DEFAULT_CHANNELS_DIR,
5
- DEFAULT_PLUGINS_DIR,
6
6
  loadConfig,
7
7
  resolvePath,
8
8
  StoredVariable,
@@ -17,30 +17,18 @@ import {
17
17
  AgentDetails,
18
18
  Channel,
19
19
  ChannelDetails,
20
- Plugin,
21
- PluginKind,
20
+ PluginDescriptor,
22
21
  Thread,
23
22
  ThreadDetails,
24
- } from '../plugins/storage.js';
25
- import { getSystemAgentDetails } from '../agents/system.js';
23
+ } from '../bus/types.js';
24
+ import type { PluginRef } from '../bus/plugin.js';
25
+ import { aiSdkPlugin } from '../plugins/ai-sdk/index.js';
26
+ import { AI_SDK_SYSTEM_PROMPT } from '../plugins/ai-sdk/system-prompt.js';
27
+ import { listBuiltInPlugins, parsePluginModule } from '../registry/plugins.js';
26
28
  import { OpenBotEvent, OpenBotState } from '../app/types.js';
29
+ import { processService } from '../harness/process.js';
27
30
  import { pathToFileURL } from 'node:url';
28
31
 
29
- const mapNameToPlugin = (
30
- name: string,
31
- description: string,
32
- kind: PluginKind = 'tool',
33
- image?: string,
34
- ): Plugin => ({
35
- id: name,
36
- name,
37
- description,
38
- kind,
39
- image,
40
- createdAt: new Date(),
41
- updatedAt: new Date(),
42
- });
43
-
44
32
  const resolveBaseDir = () => {
45
33
  const config = loadConfig();
46
34
  return resolvePath(config.baseDir || DEFAULT_BASE_DIR);
@@ -62,15 +50,6 @@ const tryReadSvgDataUrl = async (filePath: string): Promise<string | null> => {
62
50
  }
63
51
  };
64
52
 
65
- /**
66
- * Auto-discovers an entity SVG avatar and returns it as a data URL.
67
- *
68
- * Search order:
69
- * 1) <entity>/assets/avatar.svg|icon.svg|image.svg|logo.svg
70
- * 2) <entity>/avatar.svg|icon.svg|image.svg|logo.svg
71
- * 3) first *.svg in <entity>/assets
72
- * 4) first *.svg in <entity>
73
- */
74
53
  const resolveEntityImageDataUrl = async (entityDir: string): Promise<string | undefined> => {
75
54
  const preferredDirs = [path.join(entityDir, 'assets'), entityDir];
76
55
 
@@ -91,7 +70,7 @@ const resolveEntityImageDataUrl = async (entityDir: string): Promise<string | un
91
70
  const dataUrl = await tryReadSvgDataUrl(path.join(dir, firstSvg.name));
92
71
  if (dataUrl) return dataUrl;
93
72
  } catch {
94
- // ignore missing/unreadable folders
73
+ // ignore
95
74
  }
96
75
  }
97
76
 
@@ -103,6 +82,69 @@ const getConversationDir = (channelId: string, threadId?: string) => {
103
82
  return threadId ? `${base}/threads/${threadId}` : base;
104
83
  };
105
84
 
85
+ /** Built-in orchestrator agent id. Not creatable as a normal disk agent. */
86
+ const SYSTEM_AGENT_ID = 'system';
87
+
88
+ const SYSTEM_DEFAULT_PLUGINS: PluginRef[] = [
89
+ { id: 'ai-sdk', config: { model: 'openai/gpt-5.4-nano' } },
90
+ { id: 'storage-tools' },
91
+ // { id: 'mcp' },
92
+ { id: 'shell' },
93
+ { id: 'delegation' },
94
+ // { id: 'ui' },
95
+ { id: 'approval' },
96
+ ];
97
+
98
+ function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails {
99
+ const defaults: AgentDetails = {
100
+ id: SYSTEM_AGENT_ID,
101
+ name: 'OpenBot',
102
+ image: undefined,
103
+ description:
104
+ 'First-party orchestration agent for OpenBot. Coordinates other agents via handoff and delegation.',
105
+ instructions: AI_SDK_SYSTEM_PROMPT,
106
+ plugins: SYSTEM_DEFAULT_PLUGINS.map((ref) => ref.id),
107
+ pluginRefs: SYSTEM_DEFAULT_PLUGINS,
108
+ createdAt: new Date(),
109
+ updatedAt: new Date(),
110
+ };
111
+
112
+ if (!overrides) return defaults;
113
+
114
+ const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
115
+ ? overrides.pluginRefs
116
+ : defaults.pluginRefs;
117
+
118
+ return {
119
+ ...defaults,
120
+ ...overrides,
121
+ id: SYSTEM_AGENT_ID,
122
+ image: overrides.image || defaults.image,
123
+ plugins: refs.map((ref) => ref.id),
124
+ pluginRefs: refs,
125
+ updatedAt: new Date(),
126
+ };
127
+ }
128
+
129
+ // Suppress unused warning until system agent customization re-uses aiSdkPlugin metadata.
130
+ void aiSdkPlugin;
131
+
132
+ const RESERVED_DISK_AGENT_IDS = new Set([SYSTEM_AGENT_ID]);
133
+
134
+ const assertValidDiskAgentId = (agentId: string): void => {
135
+ if (!agentId || typeof agentId !== 'string') {
136
+ throw new Error('agentId is required');
137
+ }
138
+ if (RESERVED_DISK_AGENT_IDS.has(agentId)) {
139
+ throw new Error(`Agent id "${agentId}" is reserved`);
140
+ }
141
+ if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) {
142
+ throw new Error('agentId must contain only letters, digits, underscores, and hyphens');
143
+ }
144
+ };
145
+
146
+ const getAgentsRootDir = () => path.join(resolveBaseDir(), DEFAULT_AGENTS_DIR);
147
+
106
148
  const getLastReadFilePath = () =>
107
149
  path.join(resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR), '_meta', 'last-read.json');
108
150
 
@@ -145,7 +187,6 @@ const toVariablesRecord = (raw: unknown): Record<string, string> => {
145
187
  return {};
146
188
  }
147
189
 
148
- // Current format: { version: number, variables: StoredVariable[] }
149
190
  if ('variables' in raw && Array.isArray((raw as { variables?: unknown }).variables)) {
150
191
  const entries = (raw as { variables: StoredVariable[] }).variables
151
192
  .filter((variable) => typeof variable?.key === 'string')
@@ -153,7 +194,6 @@ const toVariablesRecord = (raw: unknown): Record<string, string> => {
153
194
  return Object.fromEntries(entries);
154
195
  }
155
196
 
156
- // Legacy format: { [key: string]: string }
157
197
  return Object.fromEntries(
158
198
  Object.entries(raw as Record<string, unknown>).map(([key, value]) => [
159
199
  key,
@@ -162,15 +202,59 @@ const toVariablesRecord = (raw: unknown): Record<string, string> => {
162
202
  );
163
203
  };
164
204
 
165
- const listBuiltInPlugins = async (): Promise<Plugin[]> => {
166
- return [
167
- mapNameToPlugin('storage', 'Built-in storage plugin'),
168
- mapNameToPlugin('ai-sdk', 'Built-in AI SDK plugin', 'runtime'),
169
- mapNameToPlugin('delegation', 'Built-in delegation plugin'),
170
- ];
205
+ const listBuiltInPluginDescriptors = async (): Promise<PluginDescriptor[]> => {
206
+ return listBuiltInPlugins().map((plugin) => ({
207
+ id: plugin.id,
208
+ name: plugin.name,
209
+ description: plugin.description,
210
+ builtIn: true,
211
+ image: plugin.image,
212
+ defaultInstructions: plugin.defaultInstructions,
213
+ configSchema: plugin.configSchema,
214
+ createdAt: new Date(),
215
+ updatedAt: new Date(),
216
+ }));
171
217
  };
172
218
 
173
- const listPluginsFromDisk = async (): Promise<Plugin[]> => {
219
+ /**
220
+ * Walk `plugins/` and yield candidate plugin ids (npm names). Includes scoped
221
+ * packages by recursing one level into directories starting with `@`.
222
+ */
223
+ const listInstalledPluginIds = async (pluginsDir: string): Promise<string[]> => {
224
+ const out: string[] = [];
225
+ let topEntries;
226
+ try {
227
+ topEntries = await fs.readdir(pluginsDir, { withFileTypes: true });
228
+ } catch {
229
+ return out;
230
+ }
231
+
232
+ for (const entry of topEntries) {
233
+ if (entry.name.startsWith('.')) continue;
234
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
235
+
236
+ if (entry.name.startsWith('@')) {
237
+ try {
238
+ const inner = await fs.readdir(path.join(pluginsDir, entry.name), { withFileTypes: true });
239
+ for (const sub of inner) {
240
+ if (sub.name.startsWith('.')) continue;
241
+ if (sub.isDirectory() || sub.isSymbolicLink()) {
242
+ out.push(`${entry.name}/${sub.name}`);
243
+ }
244
+ }
245
+ } catch {
246
+ // ignore
247
+ }
248
+ continue;
249
+ }
250
+
251
+ out.push(entry.name);
252
+ }
253
+
254
+ return out;
255
+ };
256
+
257
+ const listPluginsFromDisk = async (): Promise<PluginDescriptor[]> => {
174
258
  const pluginsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_PLUGINS_DIR);
175
259
  try {
176
260
  await fs.access(pluginsDir);
@@ -178,26 +262,64 @@ const listPluginsFromDisk = async (): Promise<Plugin[]> => {
178
262
  await fs.mkdir(pluginsDir, { recursive: true });
179
263
  }
180
264
 
181
- const plugins = (await fs.readdir(pluginsDir, { withFileTypes: true }))
182
- .filter(
183
- (entry) => !entry.name.startsWith('.') && (entry.isDirectory() || entry.isSymbolicLink()),
184
- )
185
- .map(async (entry) => {
186
- // get dist/index module and find inside module.plugin.description
187
- const module = await import(pathToFileURL(`${pluginsDir}/${entry.name}/dist/index.js`).href);
188
- const pluginDir = path.join(pluginsDir, entry.name);
189
- const image = await resolveEntityImageDataUrl(pluginDir);
190
- return mapNameToPlugin(
191
- module.plugin.name || entry.name,
192
- module.plugin.description || '',
193
- module.plugin.kind || 'tool',
194
- image,
195
- );
196
- });
265
+ const ids = await listInstalledPluginIds(pluginsDir);
197
266
 
198
- return Promise.all(plugins);
267
+ const descriptors = await Promise.all(
268
+ ids.map(async (id): Promise<PluginDescriptor | null> => {
269
+ try {
270
+ const pluginDir = path.join(pluginsDir, id);
271
+ const distPath = path.join(pluginDir, 'dist', 'index.js');
272
+ const module = await import(pathToFileURL(distPath).href);
273
+ const parsed = parsePluginModule(module as Record<string, unknown>);
274
+ if (!parsed) return null;
275
+ const image = await resolveEntityImageDataUrl(pluginDir);
276
+ return {
277
+ id,
278
+ name: parsed.name || id,
279
+ description: parsed.description || '',
280
+ builtIn: false,
281
+ image: parsed.image || image,
282
+ defaultInstructions: parsed.defaultInstructions,
283
+ configSchema: parsed.configSchema,
284
+ createdAt: new Date(),
285
+ updatedAt: new Date(),
286
+ };
287
+ } catch (error) {
288
+ console.warn(`[storage] Failed to load plugin ${id}:`, error);
289
+ return null;
290
+ }
291
+ }),
292
+ );
293
+
294
+ return descriptors.filter((d): d is PluginDescriptor => d !== null);
199
295
  };
200
296
 
297
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
298
+ !!value && typeof value === 'object' && !Array.isArray(value);
299
+
300
+ /**
301
+ * Parse the `plugins:` array from AGENT.md frontmatter. Each entry must have an
302
+ * `id`; `config` is optional. Strings are accepted as a shorthand for `{ id }`.
303
+ */
304
+ const parsePluginRefs = (raw: unknown): PluginRef[] => {
305
+ if (!Array.isArray(raw)) return [];
306
+ const refs: PluginRef[] = [];
307
+ for (const entry of raw) {
308
+ if (typeof entry === 'string' && entry.trim()) {
309
+ refs.push({ id: entry.trim() });
310
+ continue;
311
+ }
312
+ if (isRecord(entry) && typeof entry.id === 'string' && entry.id.trim()) {
313
+ const config = isRecord(entry.config) ? (entry.config as Record<string, unknown>) : undefined;
314
+ refs.push({ id: entry.id.trim(), ...(config ? { config } : {}) });
315
+ }
316
+ }
317
+ return refs;
318
+ };
319
+
320
+ const serializePluginRefs = (refs: PluginRef[]): unknown[] =>
321
+ refs.map((ref) => (ref.config ? { id: ref.id, config: ref.config } : { id: ref.id }));
322
+
201
323
  export const storageService = {
202
324
  getLastReadByChannel: async (): Promise<Record<string, string>> => {
203
325
  return readJsonFile(getLastReadFilePath(), {});
@@ -241,7 +363,7 @@ export const storageService = {
241
363
  const state = JSON.parse(stateContent);
242
364
  cwd = typeof state.cwd === 'string' ? state.cwd : undefined;
243
365
  } catch {
244
- // Ignore if state.json is missing or invalid
366
+ // ignore
245
367
  }
246
368
 
247
369
  const channel: Channel = {
@@ -262,7 +384,6 @@ export const storageService = {
262
384
  }
263
385
 
264
386
  try {
265
- // Fetch up to 5 most recent threads for the sidebar
266
387
  const allThreads = await storageService.getThreads({ channelId: name });
267
388
  channel.recentThreads = allThreads
268
389
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
@@ -300,8 +421,9 @@ export const storageService = {
300
421
  try {
301
422
  await fs.access(channelDir);
302
423
  throw new Error(`Channel "${normalizedChannelId}" already exists`);
303
- } catch (error: any) {
304
- if (error.code !== 'ENOENT') {
424
+ } catch (error: unknown) {
425
+ const code = (error as NodeJS.ErrnoException)?.code;
426
+ if (code !== 'ENOENT') {
305
427
  throw error;
306
428
  }
307
429
  }
@@ -311,7 +433,7 @@ export const storageService = {
311
433
  };
312
434
 
313
435
  if (cwd) {
314
- finalState.cwd = cwd;
436
+ (finalState as Record<string, unknown>).cwd = cwd;
315
437
  }
316
438
 
317
439
  await fs.mkdir(channelDir, { recursive: true });
@@ -338,12 +460,8 @@ export const storageService = {
338
460
  const normalizedChannelId = channelId.trim();
339
461
  const normalizedThreadId = threadId.trim();
340
462
 
341
- if (!normalizedChannelId) {
342
- throw new Error('channelId is required');
343
- }
344
- if (!normalizedThreadId) {
345
- throw new Error('threadId is required');
346
- }
463
+ if (!normalizedChannelId) throw new Error('channelId is required');
464
+ if (!normalizedThreadId) throw new Error('threadId is required');
347
465
 
348
466
  const threadDir = getConversationDir(normalizedChannelId, normalizedThreadId);
349
467
  const specPath = `${threadDir}/SPEC.md`;
@@ -354,13 +472,14 @@ export const storageService = {
354
472
  throw new Error(
355
473
  `Thread "${normalizedThreadId}" already exists in channel "${normalizedChannelId}"`,
356
474
  );
357
- } catch (error: any) {
358
- if (error.code !== 'ENOENT') {
475
+ } catch (error: unknown) {
476
+ const code = (error as NodeJS.ErrnoException)?.code;
477
+ if (code !== 'ENOENT') {
359
478
  throw error;
360
479
  }
361
480
  }
362
481
 
363
- const baseState = { ...(initialState || {}) };
482
+ const baseState: Record<string, unknown> = { ...(initialState || {}) };
364
483
  if (threadTitle?.trim()) {
365
484
  baseState.generatedName = threadTitle.trim();
366
485
  }
@@ -400,8 +519,8 @@ export const storageService = {
400
519
  if (generatedName) {
401
520
  threadDisplayName = generatedName;
402
521
  }
403
- } catch (error: any) {
404
- if (error.code !== 'ENOENT') {
522
+ } catch (error: unknown) {
523
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
405
524
  console.error(
406
525
  `Failed to read thread state for channel ${channelId} thread ${name}`,
407
526
  error,
@@ -435,8 +554,8 @@ export const storageService = {
435
554
  let spec = '';
436
555
  try {
437
556
  spec = await fs.readFile(specPath, 'utf-8');
438
- } catch (error: any) {
439
- if (error.code !== 'ENOENT') {
557
+ } catch (error: unknown) {
558
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
440
559
  console.error(
441
560
  `Failed to read thread spec for channel ${channelId} thread ${threadId}`,
442
561
  error,
@@ -444,12 +563,12 @@ export const storageService = {
444
563
  }
445
564
  }
446
565
 
447
- let state = {};
566
+ let state: unknown = {};
448
567
  try {
449
568
  const stateContent = await fs.readFile(statePath, 'utf-8');
450
569
  state = JSON.parse(stateContent);
451
- } catch (error: any) {
452
- if (error.code !== 'ENOENT') {
570
+ } catch (error: unknown) {
571
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
453
572
  console.error(
454
573
  `Failed to read thread state for channel ${channelId} thread ${threadId}`,
455
574
  error,
@@ -458,8 +577,8 @@ export const storageService = {
458
577
  }
459
578
 
460
579
  const generatedName =
461
- typeof (state as Record<string, unknown>).generatedName === 'string'
462
- ? ((state as Record<string, unknown>).generatedName as string).trim()
580
+ isRecord(state) && typeof state.generatedName === 'string'
581
+ ? state.generatedName.trim()
463
582
  : '';
464
583
 
465
584
  return {
@@ -478,28 +597,30 @@ export const storageService = {
478
597
  let spec = '';
479
598
  try {
480
599
  spec = await fs.readFile(specPath, 'utf-8');
481
- } catch (error: any) {
482
- if (error.code !== 'ENOENT') {
600
+ } catch (error: unknown) {
601
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
483
602
  console.error(`Failed to read spec file for channel ${channelId}`, error);
484
603
  }
485
604
  }
486
605
 
487
- let state = {};
606
+ let state: unknown = {};
488
607
  try {
489
608
  const stateContent = await fs.readFile(statePath, 'utf-8');
490
609
  state = JSON.parse(stateContent);
491
- } catch (error: any) {
492
- if (error.code !== 'ENOENT') {
610
+ } catch (error: unknown) {
611
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
493
612
  console.error(`Failed to read state file for channel ${channelId}`, error);
494
613
  }
495
614
  }
496
615
 
616
+ const cwd = isRecord(state) && typeof state.cwd === 'string' ? state.cwd : undefined;
617
+
497
618
  const details: ChannelDetails = {
498
619
  id: channelId,
499
620
  name: channelId,
500
621
  spec,
501
622
  state,
502
- cwd: typeof (state as any).cwd === 'string' ? (state as any).cwd : undefined,
623
+ cwd,
503
624
  };
504
625
 
505
626
  details.threads = await storageService.getThreads({ channelId });
@@ -517,17 +638,14 @@ export const storageService = {
517
638
  const statePath = `${channelDir}/state.json`;
518
639
 
519
640
  try {
520
- // 1. Fetch current details to get the existing state
521
641
  const currentDetails = await storageService.getChannelDetails({ channelId });
522
642
  const currentState = (currentDetails.state as Record<string, unknown>) || {};
523
643
 
524
- // 2. Perform a shallow merge (patch)
525
644
  const newState = {
526
645
  ...currentState,
527
646
  ...(patch as Record<string, unknown>),
528
647
  };
529
648
 
530
- // 3. Write back the merged state
531
649
  await fs.mkdir(channelDir, { recursive: true });
532
650
  await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
533
651
  } catch (error) {
@@ -548,17 +666,14 @@ export const storageService = {
548
666
  const statePath = `${threadDir}/state.json`;
549
667
 
550
668
  try {
551
- // 1. Fetch current details to get the existing state
552
669
  const currentDetails = await storageService.getThreadDetails({ channelId, threadId });
553
670
  const currentState = (currentDetails.state as Record<string, unknown>) || {};
554
671
 
555
- // 2. Perform a shallow merge (patch)
556
672
  const newState = {
557
673
  ...currentState,
558
674
  ...(patch as Record<string, unknown>),
559
675
  };
560
676
 
561
- // 3. Write back the merged state
562
677
  await fs.mkdir(threadDir, { recursive: true });
563
678
  await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
564
679
  } catch (error) {
@@ -629,29 +744,30 @@ export const storageService = {
629
744
  name: details.name || id,
630
745
  description: details.description || '',
631
746
  image: details.image,
632
- runtime: details.runtime,
747
+ plugins: details.plugins,
633
748
  createdAt: details.createdAt,
634
749
  updatedAt: details.updatedAt,
635
- };
750
+ } satisfies Agent;
636
751
  } catch {
637
752
  return {
638
753
  id,
639
754
  name: id,
640
755
  description: '',
756
+ plugins: [],
641
757
  createdAt: new Date(),
642
758
  updatedAt: new Date(),
643
- };
759
+ } satisfies Agent;
644
760
  }
645
761
  }),
646
762
  );
647
763
 
648
- const system = await storageService.getAgentDetails({ agentId: 'system' });
764
+ const system = await storageService.getAgentDetails({ agentId: SYSTEM_AGENT_ID });
649
765
  const builtInSystemAgent: Agent = {
650
766
  id: system.id,
651
767
  name: system.name,
652
768
  description: system.description || '',
653
769
  image: system.image,
654
- runtime: system.runtime,
770
+ plugins: system.plugins,
655
771
  createdAt: system.createdAt,
656
772
  updatedAt: system.updatedAt,
657
773
  };
@@ -664,20 +780,19 @@ export const storageService = {
664
780
 
665
781
  return Array.from(deduped.values());
666
782
  },
667
- getPlugins: async (): Promise<Plugin[]> => {
668
- const [builtInPlugins, diskPlugins] = await Promise.all([
669
- listBuiltInPlugins(),
783
+ getPlugins: async (): Promise<PluginDescriptor[]> => {
784
+ const [builtIn, fromDisk] = await Promise.all([
785
+ listBuiltInPluginDescriptors(),
670
786
  listPluginsFromDisk(),
671
787
  ]);
672
788
 
673
- const merged = [...builtInPlugins, ...diskPlugins];
674
- const deduped = new Map<string, Plugin>();
789
+ const merged = [...builtIn, ...fromDisk];
790
+ const deduped = new Map<string, PluginDescriptor>();
675
791
  for (const plugin of merged) {
676
792
  if (!deduped.has(plugin.id)) {
677
793
  deduped.set(plugin.id, plugin);
678
794
  }
679
795
  }
680
-
681
796
  return Array.from(deduped.values());
682
797
  },
683
798
  getAgentDetails: async ({ agentId }: { agentId: string }): Promise<AgentDetails> => {
@@ -691,27 +806,32 @@ export const storageService = {
691
806
  const agentMd = await fs.readFile(agentMdPath, 'utf-8');
692
807
  const { data, content: instructions } = matter(agentMd);
693
808
  const discoveredImage = await resolveEntityImageDataUrl(agentDir);
809
+ const stats = await fs.stat(agentMdPath);
810
+
811
+ const pluginRefs = parsePluginRefs(data.plugins);
694
812
 
695
813
  diskDetails = {
696
814
  id: agentId,
697
- name: data.name || agentId,
815
+ name: typeof data.name === 'string' ? data.name : agentId,
698
816
  instructions: instructions.trim(),
699
- runtime: data.runtime,
700
- plugins: data.plugins || [],
701
- description: data.description || '',
817
+ plugins: pluginRefs.map((ref) => ref.id),
818
+ pluginRefs,
819
+ description: typeof data.description === 'string' ? data.description : '',
702
820
  image: discoveredImage || undefined,
703
- createdAt: new Date(),
704
- updatedAt: new Date(),
821
+ createdAt: stats.birthtime,
822
+ updatedAt: stats.mtime,
705
823
  };
706
824
  } catch (error) {
707
- if (agentId !== 'system') {
825
+ if (agentId !== SYSTEM_AGENT_ID) {
708
826
  const err = new Error(`Agent "${agentId}" does not exist.`);
709
827
  (err as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
710
828
  throw err;
711
829
  }
830
+ // swallow: system agent has on-disk overrides optional
831
+ void error;
712
832
  }
713
833
 
714
- if (agentId === 'system') {
834
+ if (agentId === SYSTEM_AGENT_ID) {
715
835
  return getSystemAgentDetails(diskDetails);
716
836
  }
717
837
 
@@ -723,6 +843,132 @@ export const storageService = {
723
843
 
724
844
  return diskDetails as AgentDetails;
725
845
  },
846
+ createAgent: async ({
847
+ agentId,
848
+ name,
849
+ description = '',
850
+ instructions,
851
+ plugins,
852
+ }: {
853
+ agentId: string;
854
+ name: string;
855
+ description?: string;
856
+ instructions: string;
857
+ plugins: PluginRef[];
858
+ }): Promise<void> => {
859
+ assertValidDiskAgentId(agentId);
860
+ const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
861
+ const agentMdPath = path.join(agentDir, 'AGENT.md');
862
+
863
+ try {
864
+ await fs.access(agentMdPath);
865
+ throw new Error(`Agent "${agentId}" already exists`);
866
+ } catch (error: unknown) {
867
+ const code = (error as NodeJS.ErrnoException)?.code;
868
+ if (code === 'ENOENT') {
869
+ // proceed
870
+ } else if (error instanceof Error && error.message.includes('already exists')) {
871
+ throw error;
872
+ } else {
873
+ throw error;
874
+ }
875
+ }
876
+
877
+ await fs.mkdir(agentDir, { recursive: true });
878
+
879
+ const data: Record<string, unknown> = {
880
+ name,
881
+ description,
882
+ plugins: serializePluginRefs(plugins),
883
+ };
884
+
885
+ const body = matter.stringify(`${instructions.trim()}\n`, data);
886
+ await fs.writeFile(agentMdPath, body, 'utf-8');
887
+ },
888
+ updateAgent: async ({
889
+ agentId,
890
+ name,
891
+ description,
892
+ instructions,
893
+ plugins,
894
+ }: {
895
+ agentId: string;
896
+ name?: string;
897
+ description?: string;
898
+ instructions?: string;
899
+ plugins?: PluginRef[];
900
+ }): Promise<void> => {
901
+ assertValidDiskAgentId(agentId);
902
+ const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
903
+ const agentMdPath = path.join(agentDir, 'AGENT.md');
904
+
905
+ let raw: string;
906
+ try {
907
+ raw = await fs.readFile(agentMdPath, 'utf-8');
908
+ } catch (error: unknown) {
909
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
910
+ const err = new Error(`Agent "${agentId}" does not exist.`);
911
+ (err as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
912
+ throw err;
913
+ }
914
+ throw error;
915
+ }
916
+
917
+ const parsed = matter(raw);
918
+ const nextData: Record<string, unknown> = { ...parsed.data };
919
+ if (name !== undefined) nextData.name = name;
920
+ if (description !== undefined) nextData.description = description;
921
+ if (plugins !== undefined) nextData.plugins = serializePluginRefs(plugins);
922
+
923
+ const nextContent = instructions !== undefined ? instructions : parsed.content;
924
+ const body = matter.stringify(`${String(nextContent).trim()}\n`, nextData);
925
+ await fs.writeFile(agentMdPath, body, 'utf-8');
926
+ },
927
+ deleteAgent: async ({ agentId }: { agentId: string }): Promise<void> => {
928
+ assertValidDiskAgentId(agentId);
929
+ const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
930
+ const agentMdPath = path.join(agentDir, 'AGENT.md');
931
+ const packageJsonPath = path.join(agentDir, 'package.json');
932
+
933
+ try {
934
+ await fs.access(agentDir);
935
+ } catch (error: unknown) {
936
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
937
+ const err = new Error(`Agent "${agentId}" does not exist.`);
938
+ (err as Error & { code?: string }).code = 'AGENT_NOT_FOUND';
939
+ throw err;
940
+ }
941
+ throw error;
942
+ }
943
+
944
+ let hasPackage = false;
945
+ let hasAgentMd = false;
946
+ try {
947
+ await fs.access(packageJsonPath);
948
+ hasPackage = true;
949
+ } catch {
950
+ // ignore
951
+ }
952
+ try {
953
+ await fs.access(agentMdPath);
954
+ hasAgentMd = true;
955
+ } catch {
956
+ // ignore
957
+ }
958
+
959
+ if (hasPackage && !hasAgentMd) {
960
+ throw new Error(
961
+ `Cannot delete TypeScript agent package "${agentId}" through this action; remove the folder manually.`,
962
+ );
963
+ }
964
+ if (!hasAgentMd) {
965
+ throw new Error(
966
+ `Agent "${agentId}" has no AGENT.md and cannot be deleted through this action.`,
967
+ );
968
+ }
969
+
970
+ await fs.rm(agentDir, { recursive: true, force: true });
971
+ },
726
972
  getEvents: async ({
727
973
  channelId,
728
974
  threadId,
@@ -748,7 +994,6 @@ export const storageService = {
748
994
  return event;
749
995
  });
750
996
 
751
- // If we are at the channel level (no threadId), check which events have threads
752
997
  if (!threadId) {
753
998
  const threadsDir = resolvePath(
754
999
  resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR + '/' + channelId + '/threads',
@@ -758,32 +1003,26 @@ export const storageService = {
758
1003
  const threadSet = new Set(threadDirs);
759
1004
 
760
1005
  return events.map((event) => {
761
- // Check if this event has a threadId associated with it
762
- // The frontend provides the threadId, and it matches the directory name on disk
763
- const threadId = event.id;
764
-
765
- // If an explicit threadId exists and has a directory, use it
766
- if (threadId && threadSet.has(threadId)) {
1006
+ const eventThreadId = event.id;
1007
+ if (eventThreadId && threadSet.has(eventThreadId)) {
767
1008
  return {
768
1009
  ...event,
769
1010
  meta: {
770
- ...(event as any)?.meta,
1011
+ ...(event.meta || {}),
771
1012
  hasThread: true,
772
1013
  },
773
1014
  };
774
1015
  }
775
-
776
1016
  return event;
777
1017
  });
778
1018
  } catch {
779
- // No threads folder or other error, just return events as is
780
1019
  return events;
781
1020
  }
782
1021
  }
783
1022
 
784
1023
  return events;
785
- } catch (error: any) {
786
- if (error.code !== 'ENOENT') {
1024
+ } catch (error: unknown) {
1025
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
787
1026
  console.error(`Failed to get events for channel ${channelId} thread ${threadId}`, error);
788
1027
  }
789
1028
  return [];
@@ -803,8 +1042,8 @@ export const storageService = {
803
1042
  if (threadId) {
804
1043
  try {
805
1044
  await fs.access(threadDir);
806
- } catch (error: any) {
807
- if (error.code === 'ENOENT') {
1045
+ } catch (error: unknown) {
1046
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
808
1047
  const threadTitle = buildThreadTitleFromEvent(event);
809
1048
  await storageService.createThread({
810
1049
  channelId,
@@ -819,7 +1058,6 @@ export const storageService = {
819
1058
  await fs.mkdir(threadDir, { recursive: true });
820
1059
  }
821
1060
 
822
- // Ensure the event has a unique ID
823
1061
  if (!event.id) {
824
1062
  event.id = crypto.randomUUID();
825
1063
  }
@@ -832,19 +1070,102 @@ export const storageService = {
832
1070
  },
833
1071
  getVariables: async (): Promise<Record<string, string | { value: string; secret: boolean }>> => {
834
1072
  const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
835
- const raw = await readJsonFile<any>(variablesFilePath, {});
836
-
837
- if (raw && typeof raw === 'object' && 'variables' in raw && Array.isArray(raw.variables)) {
838
- const entries = (raw.variables as StoredVariable[])
1073
+ const raw = await readJsonFile<unknown>(variablesFilePath, {});
1074
+
1075
+ if (
1076
+ raw &&
1077
+ typeof raw === 'object' &&
1078
+ 'variables' in raw &&
1079
+ Array.isArray((raw as { variables: unknown }).variables)
1080
+ ) {
1081
+ const entries = ((raw as { variables: StoredVariable[] }).variables)
839
1082
  .filter((v) => typeof v?.key === 'string')
840
1083
  .map((v) => [v.key, { value: String(v.value ?? ''), secret: !!v.secret }] as const);
841
1084
  return Object.fromEntries(entries);
842
1085
  }
843
1086
 
844
- // Legacy or simple format
845
1087
  return toVariablesRecord(raw);
846
1088
  },
847
1089
 
1090
+ createVariable: async ({
1091
+ key,
1092
+ value,
1093
+ secret = false,
1094
+ }: {
1095
+ key: string;
1096
+ value: string;
1097
+ secret?: boolean;
1098
+ }): Promise<void> => {
1099
+ const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
1100
+ const raw = await readJsonFile<unknown>(variablesFilePath, { version: 1, variables: [] });
1101
+
1102
+ let variables: StoredVariable[] = [];
1103
+ if (
1104
+ raw &&
1105
+ typeof raw === 'object' &&
1106
+ 'variables' in raw &&
1107
+ Array.isArray((raw as { variables: unknown }).variables)
1108
+ ) {
1109
+ variables = (raw as { variables: StoredVariable[] }).variables;
1110
+ } else {
1111
+ variables = Object.entries(toVariablesRecord(raw)).map(([k, v]) => ({
1112
+ key: k,
1113
+ value: v,
1114
+ secret: false,
1115
+ }));
1116
+ }
1117
+
1118
+ const existingIndex = variables.findIndex((v) => v.key === key);
1119
+ if (existingIndex !== -1) {
1120
+ variables[existingIndex] = { key, value, secret };
1121
+ } else {
1122
+ variables.push({ key, value, secret });
1123
+ }
1124
+
1125
+ await fs.mkdir(path.dirname(variablesFilePath), { recursive: true });
1126
+ await fs.writeFile(
1127
+ variablesFilePath,
1128
+ JSON.stringify({ version: 1, variables }, null, 2),
1129
+ 'utf-8',
1130
+ );
1131
+ processService.syncWorkspaceVariablesToProcessEnv();
1132
+ },
1133
+
1134
+ deleteVariable: async ({ key }: { key: string }): Promise<void> => {
1135
+ const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
1136
+ const raw = await readJsonFile<unknown>(variablesFilePath, { version: 1, variables: [] });
1137
+
1138
+ let variables: StoredVariable[] = [];
1139
+ if (
1140
+ raw &&
1141
+ typeof raw === 'object' &&
1142
+ 'variables' in raw &&
1143
+ Array.isArray((raw as { variables: unknown }).variables)
1144
+ ) {
1145
+ variables = (raw as { variables: StoredVariable[] }).variables;
1146
+ } else {
1147
+ variables = Object.entries(toVariablesRecord(raw)).map(([k, v]) => ({
1148
+ key: k,
1149
+ value: v,
1150
+ secret: false,
1151
+ }));
1152
+ }
1153
+
1154
+ const newVariables = variables.filter((v) => v.key !== key);
1155
+
1156
+ if (newVariables.length === variables.length) {
1157
+ return;
1158
+ }
1159
+
1160
+ await fs.mkdir(path.dirname(variablesFilePath), { recursive: true });
1161
+ await fs.writeFile(
1162
+ variablesFilePath,
1163
+ JSON.stringify({ version: 1, variables: newVariables }, null, 2),
1164
+ 'utf-8',
1165
+ );
1166
+ processService.syncWorkspaceVariablesToProcessEnv();
1167
+ },
1168
+
848
1169
  listFiles: async ({
849
1170
  channelId,
850
1171
  path: subPath = '',
@@ -862,21 +1183,26 @@ export const storageService = {
862
1183
  const resolvedBase = path.resolve(baseCwd);
863
1184
  const targetDir = path.resolve(resolvedBase, subPath);
864
1185
 
865
- // Security check: ensure target is within baseCwd
866
1186
  if (!targetDir.startsWith(resolvedBase)) {
867
1187
  throw new Error('Access denied: directory escape');
868
1188
  }
869
1189
 
870
1190
  const entries = await fs.readdir(targetDir, { withFileTypes: true });
871
1191
  return entries
872
- .filter((e) => !e.name.startsWith('.')) // Hide hidden files by default for MVP
1192
+ .filter((e) => !e.name.startsWith('.'))
873
1193
  .map((e) => ({
874
1194
  name: e.name,
875
1195
  isDirectory: e.isDirectory(),
876
1196
  }));
877
1197
  },
878
1198
 
879
- readFile: async ({ channelId, path: filePath }: { channelId: string; path: string }): Promise<string> => {
1199
+ readFile: async ({
1200
+ channelId,
1201
+ path: filePath,
1202
+ }: {
1203
+ channelId: string;
1204
+ path: string;
1205
+ }): Promise<string> => {
880
1206
  const details = await storageService.getChannelDetails({ channelId });
881
1207
  const baseCwd = details.cwd;
882
1208
 
@@ -887,7 +1213,6 @@ export const storageService = {
887
1213
  const resolvedBase = path.resolve(baseCwd);
888
1214
  const targetFile = path.resolve(resolvedBase, filePath);
889
1215
 
890
- // Security check: ensure target is within baseCwd
891
1216
  if (!targetFile.startsWith(resolvedBase)) {
892
1217
  throw new Error('Access denied: directory escape');
893
1218
  }
@@ -916,7 +1241,7 @@ export const storageService = {
916
1241
  }
917
1242
 
918
1243
  let channelDetails;
919
- if (channelId && channelId !== 'general') {
1244
+ if (channelId) {
920
1245
  try {
921
1246
  channelDetails = await storageService.getChannelDetails({ channelId });
922
1247
  } catch (error) {
@@ -946,10 +1271,13 @@ export const storageService = {
946
1271
  id: agentDetails.id,
947
1272
  name: agentDetails.name,
948
1273
  description: agentDetails.description || '',
1274
+ image: agentDetails.image,
949
1275
  instructions: agentDetails.instructions || '',
950
- runtime: agentDetails.runtime,
951
1276
  plugins: agentDetails.plugins,
952
- } as AgentDetails,
1277
+ pluginRefs: agentDetails.pluginRefs,
1278
+ createdAt: agentDetails.createdAt,
1279
+ updatedAt: agentDetails.updatedAt,
1280
+ } satisfies AgentDetails,
953
1281
  channelDetails: channelDetails as ChannelDetails,
954
1282
  threadDetails: threadDetails as ThreadDetails,
955
1283
  };