openbot 0.4.0 → 0.4.2

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 (50) hide show
  1. package/dist/app/cli.js +1 -1
  2. package/dist/app/config.js +10 -0
  3. package/dist/app/server.js +200 -3
  4. package/dist/harness/index.js +18 -0
  5. package/dist/plugins/approval/index.js +35 -20
  6. package/dist/plugins/bash/index.js +195 -0
  7. package/dist/plugins/delegation/index.js +4 -2
  8. package/dist/plugins/openbot/context.js +54 -9
  9. package/dist/plugins/openbot/history.js +47 -1
  10. package/dist/plugins/openbot/index.js +43 -3
  11. package/dist/plugins/openbot/runtime.js +91 -27
  12. package/dist/plugins/openbot/system-prompt.js +21 -1
  13. package/dist/plugins/plugin-manager/index.js +87 -3
  14. package/dist/plugins/shell/index.js +2 -1
  15. package/dist/plugins/storage/files.js +67 -0
  16. package/dist/plugins/storage/index.js +184 -7
  17. package/dist/plugins/storage/service.js +201 -44
  18. package/dist/plugins/ui/index.js +109 -150
  19. package/dist/services/abort.js +43 -0
  20. package/dist/services/plugins/registry.js +5 -3
  21. package/dist/services/plugins/service.js +66 -11
  22. package/docs/agents.md +5 -8
  23. package/docs/architecture.md +1 -1
  24. package/docs/plugins.md +28 -7
  25. package/docs/templates/AGENT.example.md +4 -4
  26. package/package.json +1 -1
  27. package/src/app/cli.ts +1 -1
  28. package/src/app/config.ts +13 -0
  29. package/src/app/server.ts +235 -3
  30. package/src/app/types.ts +284 -14
  31. package/src/harness/index.ts +21 -0
  32. package/src/plugins/approval/index.ts +37 -20
  33. package/src/plugins/bash/index.ts +232 -0
  34. package/src/plugins/delegation/index.ts +5 -2
  35. package/src/plugins/openbot/context.ts +58 -9
  36. package/src/plugins/openbot/history.ts +52 -1
  37. package/src/plugins/openbot/index.ts +45 -3
  38. package/src/plugins/openbot/runtime.ts +121 -27
  39. package/src/plugins/openbot/system-prompt.ts +21 -1
  40. package/src/plugins/plugin-manager/index.ts +105 -3
  41. package/src/plugins/storage/files.ts +81 -0
  42. package/src/plugins/storage/index.ts +198 -8
  43. package/src/plugins/storage/service.ts +267 -44
  44. package/src/plugins/ui/index.ts +123 -0
  45. package/src/services/abort.ts +46 -0
  46. package/src/services/plugins/domain.ts +34 -1
  47. package/src/services/plugins/registry.ts +5 -3
  48. package/src/services/plugins/service.ts +136 -45
  49. package/src/services/plugins/types.ts +5 -1
  50. package/src/plugins/shell/index.ts +0 -123
package/src/app/server.ts CHANGED
@@ -13,7 +13,14 @@ import { ActiveRunsSnapshotEvent, OpenBotEvent, OpenBotState } from './types.js'
13
13
  import { processService } from '../services/process.js';
14
14
  import { runAgent, STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
15
15
  import { initPlugins } from '../services/plugins/registry.js';
16
+ import { storageService } from '../plugins/storage/service.js';
17
+ import {
18
+ buildWorkspaceFileUrl,
19
+ getPublicBaseUrl,
20
+ openChannelFileStream,
21
+ } from '../plugins/storage/files.js';
16
22
  import { ensureEventId, openBotEventFromQuery } from './utils.js';
23
+ import { abortRegistry, abortKey } from '../services/abort.js';
17
24
 
18
25
  type Bucket = { channelId: string; threadId?: string; activeCount: number; agentIds: Set<string> };
19
26
 
@@ -53,6 +60,10 @@ export async function startServer(options: ServerOptions = {}) {
53
60
 
54
61
  initPlugins(pluginsDir);
55
62
 
63
+ // Pre-warm caches for agents and plugins to speed up first UI load
64
+ storageService.getAgents().catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
65
+ storageService.getPlugins().catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
66
+
56
67
  const getContext = (req: express.Request) => {
57
68
  const channelId =
58
69
  req.get('x-openbot-channel-id') || req.query.channelId || (req.body && req.body.channelId);
@@ -71,7 +82,7 @@ export async function startServer(options: ServerOptions = {}) {
71
82
  (req.body && req.body.responseType);
72
83
 
73
84
  return {
74
- channelId: (channelId || (threadId ? 'general' : 'general')) as string, // Default to general if none
85
+ channelId: (channelId || (threadId ? 'uncategorized' : 'uncategorized')) as string, // Default to uncategorized if none
75
86
  threadId: threadId as string | undefined,
76
87
  agentId: agentId as string | undefined,
77
88
  runId: runId as string,
@@ -86,7 +97,16 @@ export async function startServer(options: ServerOptions = {}) {
86
97
 
87
98
  const sendToClientKey = (clientKey: string, chunk: OpenBotEvent) => {
88
99
  const threadClients = clients.get(clientKey);
89
- if (!threadClients) return;
100
+ if (!threadClients || threadClients.length === 0) return;
101
+
102
+ // Auto-detect "read" state: if someone is listening, they just "read" this event.
103
+ if (chunk.id && clientKey !== GLOBAL_CHANNEL_ID) {
104
+ const parts = clientKey.split(':');
105
+ const channelId = parts[0];
106
+ const threadId = parts[1]; // undefined if no ":"
107
+ storageService.setLastRead({ channelId, threadId, lastReadEventId: chunk.id }).catch(() => {});
108
+ }
109
+
90
110
  threadClients.forEach((client) => {
91
111
  if (!client.writableEnded) {
92
112
  client.write(`data: ${JSON.stringify(chunk)}\n\n`);
@@ -130,8 +150,37 @@ export async function startServer(options: ServerOptions = {}) {
130
150
  };
131
151
  };
132
152
 
153
+ // Drop every tracked run for a channel/thread. A stop aborts the whole
154
+ // chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
155
+ // events can be swallowed when the parent run loop breaks on abort, leaving
156
+ // orphaned entries that keep a channel falsely "active". Purging by
157
+ // channel/thread guarantees the snapshot self-heals after a stop.
158
+ const purgeActiveRunsForThread = (channelId: string, threadId?: string): void => {
159
+ const target = threadId || undefined;
160
+ for (const [key, run] of activeRuns) {
161
+ if (run.channelId === channelId && (run.threadId || undefined) === target) {
162
+ activeRuns.delete(key);
163
+ }
164
+ }
165
+ };
166
+
133
167
  app.use(cors());
134
- app.use(express.json({ limit: '20mb' }));
168
+
169
+ const resolvePublicBaseUrl = () => getPublicBaseUrl(PORT, config.publicUrl);
170
+
171
+ app.use((req, res, next) => {
172
+ const isWorkspaceUpload =
173
+ req.method === 'POST' &&
174
+ req.path === '/api/publish' &&
175
+ req.get('x-openbot-event-type') === 'action:storage:upload-file';
176
+
177
+ if (isWorkspaceUpload) {
178
+ express.raw({ type: () => true, limit: '100mb' })(req, res, next);
179
+ return;
180
+ }
181
+
182
+ express.json({ limit: '20mb' })(req, res, next);
183
+ });
135
184
 
136
185
  app.get('/api/health', (req, res) => {
137
186
  res.json({ status: 'ok', version: pkg.version });
@@ -161,6 +210,19 @@ export async function startServer(options: ServerOptions = {}) {
161
210
  }
162
211
  clients.get(clientKey)!.push(res);
163
212
 
213
+ // Auto-detect "read" state on connection: mark the latest event as seen.
214
+ if (channelId !== GLOBAL_CHANNEL_ID) {
215
+ storageService
216
+ .getEvents({ channelId, threadId })
217
+ .then((events) => {
218
+ const latestId = events[events.length - 1]?.id;
219
+ if (latestId) {
220
+ return storageService.setLastRead({ channelId, threadId, lastReadEventId: latestId });
221
+ }
222
+ })
223
+ .catch(() => {});
224
+ }
225
+
164
226
  if (channelId === GLOBAL_CHANNEL_ID) {
165
227
  const snapshot = buildActiveRunsSnapshot();
166
228
 
@@ -192,6 +254,57 @@ export async function startServer(options: ServerOptions = {}) {
192
254
  });
193
255
 
194
256
  app.post('/api/publish', async (req, res) => {
257
+ if (req.get('x-openbot-event-type') === 'action:storage:upload-file') {
258
+ const channelId =
259
+ req.get('x-openbot-channel-id') ||
260
+ (typeof req.query.channelId === 'string' ? req.query.channelId : undefined);
261
+ const filePath = req.get('x-openbot-file-path');
262
+ const overwrite = req.get('x-openbot-file-overwrite') === 'true';
263
+
264
+ if (!channelId?.trim()) {
265
+ res.status(400).json({ error: 'channelId is required' });
266
+ return;
267
+ }
268
+ if (!filePath?.trim()) {
269
+ res.status(400).json({ error: 'x-openbot-file-path header is required' });
270
+ return;
271
+ }
272
+
273
+ const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
274
+ if (body.length === 0) {
275
+ res.status(400).json({ error: 'Request body is empty' });
276
+ return;
277
+ }
278
+
279
+ try {
280
+ const result = await storageService.uploadChannelFile({
281
+ channelId: channelId.trim(),
282
+ path: filePath.trim(),
283
+ body,
284
+ overwrite,
285
+ });
286
+ const url = buildWorkspaceFileUrl({
287
+ baseUrl: resolvePublicBaseUrl(),
288
+ channelId: channelId.trim(),
289
+ filePath: result.path,
290
+ });
291
+ res.json({
292
+ type: 'action:storage:upload-file:result',
293
+ data: { success: true, ...result, url },
294
+ });
295
+ } catch (error) {
296
+ res.status(400).json({
297
+ type: 'action:storage:upload-file:result',
298
+ data: {
299
+ success: false,
300
+ path: filePath,
301
+ error: error instanceof Error ? error.message : 'Upload failed',
302
+ },
303
+ });
304
+ }
305
+ return;
306
+ }
307
+
195
308
  const parseResult = publishEventSchema.safeParse(req.body);
196
309
  if (!parseResult.success) {
197
310
  res.status(400).json({
@@ -210,6 +323,92 @@ export async function startServer(options: ServerOptions = {}) {
210
323
  return;
211
324
  }
212
325
 
326
+ if (event.type === 'action:storage:write-file') {
327
+ const data = (event.data ?? {}) as {
328
+ path?: string;
329
+ content?: string;
330
+ encoding?: 'utf8' | 'base64';
331
+ overwrite?: boolean;
332
+ };
333
+
334
+ if (!data.path?.trim()) {
335
+ res.status(400).json({
336
+ type: 'action:storage:write-file:result',
337
+ data: { success: false, path: '', error: 'path is required' },
338
+ });
339
+ return;
340
+ }
341
+ if (typeof data.content !== 'string') {
342
+ res.status(400).json({
343
+ type: 'action:storage:write-file:result',
344
+ data: { success: false, path: data.path, error: 'content is required' },
345
+ });
346
+ return;
347
+ }
348
+
349
+ try {
350
+ const result = await storageService.writeChannelFile({
351
+ channelId,
352
+ path: data.path.trim(),
353
+ content: data.content,
354
+ encoding: data.encoding ?? 'utf8',
355
+ overwrite: data.overwrite ?? false,
356
+ });
357
+ const url = buildWorkspaceFileUrl({
358
+ baseUrl: resolvePublicBaseUrl(),
359
+ channelId,
360
+ filePath: result.path,
361
+ });
362
+ res.json({
363
+ type: 'action:storage:write-file:result',
364
+ data: { success: true, ...result, url },
365
+ });
366
+ } catch (error) {
367
+ res.status(400).json({
368
+ type: 'action:storage:write-file:result',
369
+ data: {
370
+ success: false,
371
+ path: data.path,
372
+ error: error instanceof Error ? error.message : 'Write failed',
373
+ },
374
+ });
375
+ }
376
+ return;
377
+ }
378
+
379
+ // Stop request: cancel the in-flight run (and any delegated sub-agents in the
380
+ // same thread) instead of spinning up a new agent turn.
381
+ if (event.type === 'action:agent_run_stop') {
382
+ const data = (event.data ?? {}) as {
383
+ runId?: string;
384
+ agentId?: string;
385
+ channelId?: string;
386
+ threadId?: string;
387
+ reason?: string;
388
+ };
389
+ const targetChannelId = data.channelId || channelId;
390
+ const targetThreadId = data.threadId || threadId;
391
+ const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
392
+ purgeActiveRunsForThread(targetChannelId, targetThreadId);
393
+
394
+ const stoppedEvent: OpenBotEvent = {
395
+ type: 'agent:run:stopped',
396
+ data: {
397
+ runId: data.runId || runId,
398
+ agentId: data.agentId || agentId || ORCHESTRATOR_AGENT_ID,
399
+ channelId: targetChannelId,
400
+ threadId: targetThreadId,
401
+ reason: data.reason,
402
+ },
403
+ } as OpenBotEvent;
404
+ ensureEventId(stoppedEvent);
405
+ sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
406
+ sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
407
+
408
+ res.json({ success: stopped });
409
+ return;
410
+ }
411
+
213
412
  const onEvent = async (chunk: OpenBotEvent, state?: OpenBotState) => {
214
413
  const targetChannelId = state?.channelId || channelId;
215
414
  const targetThreadId = state?.threadId || threadId;
@@ -229,6 +428,8 @@ export async function startServer(options: ServerOptions = {}) {
229
428
  activeRuns.delete(
230
429
  getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId),
231
430
  );
431
+ } else if (chunk.type === 'agent:run:stopped') {
432
+ purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
232
433
  }
233
434
 
234
435
  sendToClientKey(targetClientKey, chunk);
@@ -251,6 +452,7 @@ export async function startServer(options: ServerOptions = {}) {
251
452
  event,
252
453
  channelId,
253
454
  threadId,
455
+ publicBaseUrl: resolvePublicBaseUrl(),
254
456
  onEvent,
255
457
  });
256
458
  res.sendStatus(200);
@@ -277,6 +479,35 @@ export async function startServer(options: ServerOptions = {}) {
277
479
  }
278
480
 
279
481
  const { channelId, threadId, agentId, runId } = getContext(req);
482
+
483
+ if (event.type === 'action:storage:serve-file') {
484
+ const filePath = (event.data as { path?: string })?.path;
485
+ if (!channelId?.trim()) {
486
+ res.status(400).json({ error: 'channelId is required' });
487
+ return;
488
+ }
489
+ if (!filePath?.trim()) {
490
+ res.status(400).json({ error: 'path is required' });
491
+ return;
492
+ }
493
+
494
+ try {
495
+ const { abs, size, mimeType } = await storageService.getChannelFileStat({
496
+ channelId,
497
+ path: filePath.trim(),
498
+ });
499
+ res.setHeader('Content-Type', mimeType);
500
+ res.setHeader('Content-Length', String(size));
501
+ res.setHeader('Cache-Control', 'private, max-age=3600');
502
+ openChannelFileStream(abs).pipe(res);
503
+ } catch (error) {
504
+ res.status(404).json({
505
+ error: error instanceof Error ? error.message : 'File not found',
506
+ });
507
+ }
508
+ return;
509
+ }
510
+
280
511
  const events: OpenBotEvent[] = [];
281
512
 
282
513
  const onEvent = async (chunk: OpenBotEvent) => {
@@ -293,6 +524,7 @@ export async function startServer(options: ServerOptions = {}) {
293
524
  channelId,
294
525
  threadId,
295
526
  persistEvents: false,
527
+ publicBaseUrl: resolvePublicBaseUrl(),
296
528
  onEvent,
297
529
  });
298
530
  res.json({ events });