openbot 0.5.8 → 0.5.10

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/CLAUDE.md CHANGED
@@ -31,7 +31,7 @@ OpenBot is a local-first, event-driven harness for running AI agents, built on t
31
31
 
32
32
  Core built-ins: `storage` (infra handlers for `/api/state`), `plugin-manager` (marketplace + npm plugin install). The default agent runtime is **`@meetopenbot/openbot`**, a community npm package installed to `~/.openbot/plugins/@meetopenbot/openbot/` on first boot (`ensureDefaultStack`). Disk-installed plugins take precedence over built-ins in `resolvePlugin`.
33
33
 
34
- A `Plugin`'s `factory(context)` returns a `MelonyPlugin`; `PluginContext` carries `agentId`, `agentDetails`, per-agent `config` (from `AGENT.md` frontmatter), the shared `storage` service, the merged `tools` map, `publicBaseUrl`, the run's `abortSignal`, and `host` (platform hooks: `runAgent`, cloud auth, model registry, config).
34
+ A `Plugin`'s `factory(context)` returns a `MelonyPlugin`; `PluginContext` carries `agentId`, `agentDetails`, per-agent `config` (from `AGENT.md` frontmatter), the shared `storage` service, the merged `tools` map, `publicBaseUrl`, the run's `abortSignal`, and `host` (platform hooks: `runAgent`, cloud auth, config).
35
35
 
36
36
  ### Agents
37
37
 
@@ -0,0 +1,70 @@
1
+ export function getRunKey(runId, agentId, channelId, threadId) {
2
+ return `${runId}:${agentId}:${channelId}:${threadId || ''}`;
3
+ }
4
+ export class ActiveRunsTracker {
5
+ constructor() {
6
+ this.runs = new Map();
7
+ }
8
+ trackStart(data) {
9
+ this.runs.set(getRunKey(data.runId, data.agentId, data.channelId, data.threadId), data);
10
+ }
11
+ trackEnd(data) {
12
+ this.runs.delete(getRunKey(data.runId, data.agentId, data.channelId, data.threadId));
13
+ }
14
+ buildSnapshot() {
15
+ const byBucket = new Map();
16
+ for (const run of this.runs.values()) {
17
+ const threadId = run.threadId || undefined;
18
+ const key = JSON.stringify([run.channelId, threadId ?? null]);
19
+ let bucket = byBucket.get(key);
20
+ if (!bucket) {
21
+ bucket = {
22
+ channelId: run.channelId,
23
+ threadId,
24
+ activeCount: 0,
25
+ agentIds: new Set(),
26
+ };
27
+ byBucket.set(key, bucket);
28
+ }
29
+ bucket.activeCount += 1;
30
+ bucket.agentIds.add(run.agentId);
31
+ }
32
+ const channels = Array.from(byBucket.values())
33
+ .sort((a, b) => {
34
+ const c = a.channelId.localeCompare(b.channelId);
35
+ if (c !== 0)
36
+ return c;
37
+ return (a.threadId ?? '').localeCompare(b.threadId ?? '');
38
+ })
39
+ .map(({ channelId, threadId, activeCount, agentIds }) => {
40
+ const row = {
41
+ channelId,
42
+ activeCount,
43
+ agentIds: Array.from(agentIds),
44
+ };
45
+ if (threadId !== undefined) {
46
+ row.threadId = threadId;
47
+ }
48
+ return row;
49
+ });
50
+ return {
51
+ type: 'agent:active-runs:snapshot',
52
+ data: { channels },
53
+ };
54
+ }
55
+ /**
56
+ * Drop every tracked run for a channel/thread. Returns the purged records so
57
+ * callers can emit synthetic `agent:run:end` events to SSE clients.
58
+ */
59
+ purgeForThread(channelId, threadId) {
60
+ const target = threadId || undefined;
61
+ const removed = [];
62
+ for (const [key, run] of this.runs) {
63
+ if (run.channelId === channelId && (run.threadId || undefined) === target) {
64
+ removed.push(run);
65
+ this.runs.delete(key);
66
+ }
67
+ }
68
+ return removed;
69
+ }
70
+ }
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.8');
21
+ program.name('openbot').description('OpenBot CLI').version('0.5.10');
22
22
  program
23
23
  .command('start')
24
24
  .description('Start the OpenBot harness')
@@ -0,0 +1,27 @@
1
+ import { storageService } from '../plugins/storage/service.js';
2
+ import { ensureEventId } from './utils.js';
3
+ export function persistLifecycleEvent(event, targetChannelId, targetThreadId) {
4
+ ensureEventId(event);
5
+ storageService
6
+ .storeEvent({
7
+ channelId: targetChannelId,
8
+ threadId: targetThreadId,
9
+ event,
10
+ })
11
+ .catch((error) => {
12
+ console.error('[server] Failed to persist lifecycle event', {
13
+ type: event.type,
14
+ channelId: targetChannelId,
15
+ threadId: targetThreadId,
16
+ error,
17
+ });
18
+ });
19
+ }
20
+ export function broadcastLifecycleEvent(event, channelId, threadId, deps, options) {
21
+ ensureEventId(event);
22
+ if (options?.persist !== false) {
23
+ persistLifecycleEvent(event, channelId, threadId);
24
+ }
25
+ deps.sendToClientKey(deps.getClientKey(channelId, threadId), event);
26
+ deps.sendToClientKey(deps.globalChannelId, event);
27
+ }
@@ -0,0 +1,37 @@
1
+ const LIFECYCLE_RUN_TYPES = new Set(['agent:run:start', 'agent:run:end', 'agent:run:stopped']);
2
+ export function createRunEventHandler(deps) {
3
+ return async (chunk, state) => {
4
+ if (chunk.type === 'action:webhook:http-response' && deps.onWebhookHttpResponse) {
5
+ deps.onWebhookHttpResponse(chunk.data);
6
+ return;
7
+ }
8
+ const targetChannelId = state?.channelId || deps.defaultChannelId;
9
+ const targetThreadId = state?.threadId || deps.defaultThreadId;
10
+ if (chunk.type === 'agent:run:start' && chunk.data.channelId) {
11
+ deps.activeRuns.trackStart({
12
+ runId: chunk.data.runId,
13
+ channelId: chunk.data.channelId,
14
+ threadId: chunk.data.threadId,
15
+ agentId: chunk.data.agentId,
16
+ });
17
+ }
18
+ else if (chunk.type === 'agent:run:end' && chunk.data.channelId) {
19
+ deps.activeRuns.trackEnd({
20
+ runId: chunk.data.runId,
21
+ channelId: chunk.data.channelId,
22
+ threadId: chunk.data.threadId,
23
+ agentId: chunk.data.agentId,
24
+ });
25
+ }
26
+ else if (chunk.type === 'agent:run:stopped' && chunk.data.channelId) {
27
+ deps.onRunStopped?.(chunk.data.channelId, chunk.data.threadId);
28
+ }
29
+ if (targetChannelId) {
30
+ const targetClientKey = deps.getClientKey(targetChannelId, targetThreadId);
31
+ deps.sendToClientKey(targetClientKey, chunk);
32
+ }
33
+ if (LIFECYCLE_RUN_TYPES.has(chunk.type)) {
34
+ deps.sendToClientKey(deps.globalChannelId, chunk);
35
+ }
36
+ };
37
+ }
@@ -10,6 +10,9 @@ const pkg = require('../../package.json');
10
10
  import { generateId } from 'melony';
11
11
  import { getBaseDir, loadConfig } from '../app/config.js';
12
12
  import { isCloudMode } from './cloud-mode.js';
13
+ import { ActiveRunsTracker } from './active-runs.js';
14
+ import { broadcastLifecycleEvent } from './lifecycle-events.js';
15
+ import { createRunEventHandler } from './run-event-handler.js';
13
16
  import { processService } from '../services/process.js';
14
17
  import { runAgent, STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
15
18
  import { initPlugins } from '../services/plugins/registry.js';
@@ -18,7 +21,8 @@ import { buildWorkspaceFileUrl, getPublicBaseUrl, openChannelFileStream, } from
18
21
  import { ensureEventId, openBotEventFromQuery } from './utils.js';
19
22
  import { abortRegistry, abortKey } from '../services/abort.js';
20
23
  import { resolvePublishTargetAgentId, resolveRespondingAgentId } from './responding-agent.js';
21
- import { DEFAULT_UNCATEGORIZED_SPEC, UNCATEGORIZED_CHANNEL_ID, } from './channel-ids.js';
24
+ import { DEFAULT_UNCATEGORIZED_SPEC, UNCATEGORIZED_CHANNEL_ID } from './channel-ids.js';
25
+ import { pickWebhookHeaders, resolveWebhookAgentId, respondAgentDispatchError, sendWebhookHttpResponse, } from './webhook-routing.js';
22
26
  export async function startServer(options = {}) {
23
27
  const publishEventSchema = z
24
28
  .object({
@@ -38,19 +42,21 @@ export async function startServer(options = {}) {
38
42
  const app = express();
39
43
  const clients = new Map();
40
44
  const GLOBAL_CHANNEL_ID = '__global__';
41
- const activeRuns = new Map();
45
+ const activeRunsTracker = new ActiveRunsTracker();
42
46
  const agentsDir = path.join(openBotDir, 'agents');
43
47
  const pluginsDir = path.join(openBotDir, 'plugins');
44
48
  await fs.mkdir(agentsDir, { recursive: true });
45
49
  await fs.mkdir(pluginsDir, { recursive: true });
46
50
  initPlugins(pluginsDir);
47
51
  // Pre-warm caches for agents and plugins to speed up first UI load
48
- storageService.getAgents().catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
49
- storageService.getPlugins().catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
52
+ storageService
53
+ .getAgents()
54
+ .catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
55
+ storageService
56
+ .getPlugins()
57
+ .catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
50
58
  const getRawChannelId = (req) => {
51
- const raw = req.get('x-openbot-channel-id') ||
52
- req.query.channelId ||
53
- (req.body && req.body.channelId);
59
+ const raw = req.get('x-openbot-channel-id') || req.query.channelId || (req.body && req.body.channelId);
54
60
  if (typeof raw !== 'string')
55
61
  return undefined;
56
62
  const trimmed = raw.trim();
@@ -77,7 +83,6 @@ export async function startServer(options = {}) {
77
83
  };
78
84
  };
79
85
  const getClientKey = (channelId, threadId) => threadId ? `${channelId}:${threadId}` : channelId;
80
- const getRunKey = (runId, agentId, channelId, threadId) => `${runId}:${agentId}:${channelId}:${threadId || ''}`;
81
86
  const sendToClientKey = (clientKey, chunk) => {
82
87
  const threadClients = clients.get(clientKey);
83
88
  if (!threadClients || threadClients.length === 0)
@@ -87,7 +92,9 @@ export async function startServer(options = {}) {
87
92
  const parts = clientKey.split(':');
88
93
  const channelId = parts[0];
89
94
  const threadId = parts[1]; // undefined if no ":"
90
- storageService.setLastRead({ channelId, threadId, lastReadEventId: chunk.id }).catch(() => { });
95
+ storageService
96
+ .setLastRead({ channelId, threadId, lastReadEventId: chunk.id })
97
+ .catch(() => { });
91
98
  }
92
99
  threadClients.forEach((client) => {
93
100
  if (!client.writableEnded) {
@@ -95,64 +102,16 @@ export async function startServer(options = {}) {
95
102
  }
96
103
  });
97
104
  };
98
- const buildActiveRunsSnapshot = () => {
99
- const byBucket = new Map();
100
- for (const run of activeRuns.values()) {
101
- const threadId = run.threadId || undefined;
102
- const key = JSON.stringify([run.channelId, threadId ?? null]);
103
- let bucket = byBucket.get(key);
104
- if (!bucket) {
105
- bucket = { channelId: run.channelId, threadId, activeCount: 0, agentIds: new Set() };
106
- byBucket.set(key, bucket);
107
- }
108
- bucket.activeCount += 1;
109
- bucket.agentIds.add(run.agentId);
110
- }
111
- const channels = Array.from(byBucket.values())
112
- .sort((a, b) => {
113
- const c = a.channelId.localeCompare(b.channelId);
114
- if (c !== 0)
115
- return c;
116
- return (a.threadId ?? '').localeCompare(b.threadId ?? '');
117
- })
118
- .map(({ channelId, threadId, activeCount, agentIds }) => {
119
- const row = {
120
- channelId,
121
- activeCount,
122
- agentIds: Array.from(agentIds),
123
- };
124
- if (threadId !== undefined) {
125
- row.threadId = threadId;
126
- }
127
- return row;
128
- });
129
- return {
130
- type: 'agent:active-runs:snapshot',
131
- data: { channels },
132
- };
105
+ const lifecycleDeps = {
106
+ sendToClientKey,
107
+ getClientKey,
108
+ globalChannelId: GLOBAL_CHANNEL_ID,
133
109
  };
134
110
  const broadcastActiveRunsSnapshot = () => {
135
- const snapshot = buildActiveRunsSnapshot();
111
+ const snapshot = activeRunsTracker.buildSnapshot();
136
112
  ensureEventId(snapshot);
137
113
  sendToClientKey(GLOBAL_CHANNEL_ID, snapshot);
138
114
  };
139
- const persistLifecycleEvent = (event, targetChannelId, targetThreadId) => {
140
- ensureEventId(event);
141
- storageService
142
- .storeEvent({
143
- channelId: targetChannelId,
144
- threadId: targetThreadId,
145
- event,
146
- })
147
- .catch((error) => {
148
- console.error('[server] Failed to persist lifecycle event', {
149
- type: event.type,
150
- channelId: targetChannelId,
151
- threadId: targetThreadId,
152
- error,
153
- });
154
- });
155
- };
156
115
  // Drop every tracked run for a channel/thread. A stop aborts the whole
157
116
  // chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
158
117
  // events can be swallowed when the parent run loop breaks on abort, leaving
@@ -160,15 +119,7 @@ export async function startServer(options = {}) {
160
119
  // `agent:run:end` for each purged run and refresh the global snapshot so
161
120
  // clients stay in sync even when the parent harness stops yielding.
162
121
  const purgeActiveRunsForThread = (channelId, threadId) => {
163
- const target = threadId || undefined;
164
- const removed = [];
165
- for (const [key, run] of activeRuns) {
166
- if (run.channelId === channelId && (run.threadId || undefined) === target) {
167
- removed.push(run);
168
- activeRuns.delete(key);
169
- }
170
- }
171
- for (const run of removed) {
122
+ for (const run of activeRunsTracker.purgeForThread(channelId, threadId)) {
172
123
  const endEvent = {
173
124
  type: 'agent:run:end',
174
125
  data: {
@@ -178,12 +129,13 @@ export async function startServer(options = {}) {
178
129
  threadId: run.threadId,
179
130
  },
180
131
  };
181
- ensureEventId(endEvent);
182
- persistLifecycleEvent(endEvent, run.channelId, run.threadId);
183
- sendToClientKey(getClientKey(run.channelId, run.threadId), endEvent);
184
- sendToClientKey(GLOBAL_CHANNEL_ID, endEvent);
132
+ broadcastLifecycleEvent(endEvent, run.channelId, run.threadId, lifecycleDeps);
185
133
  }
186
134
  };
135
+ const handleRunStopped = (channelId, threadId) => {
136
+ purgeActiveRunsForThread(channelId, threadId);
137
+ broadcastActiveRunsSnapshot();
138
+ };
187
139
  // Support for Chrome's Private Network Access (PNA)
188
140
  // https://developer.chrome.com/blog/private-network-access-preflight/
189
141
  app.use((req, res, next) => {
@@ -200,7 +152,9 @@ export async function startServer(options = {}) {
200
152
  const gatewayToken = process.env.OPENBOT_GATEWAY_TOKEN?.trim();
201
153
  if (gatewayToken) {
202
154
  app.use((req, res, next) => {
203
- if (req.path === '/api/health' || req.method === 'OPTIONS') {
155
+ if (req.path === '/api/health' ||
156
+ req.method === 'OPTIONS' ||
157
+ req.path.startsWith('/api/webhooks/')) {
204
158
  next();
205
159
  return;
206
160
  }
@@ -217,8 +171,9 @@ export async function startServer(options = {}) {
217
171
  const isWorkspaceUpload = req.method === 'POST' &&
218
172
  req.path === '/api/publish' &&
219
173
  req.get('x-openbot-event-type') === 'action:storage:upload-file';
220
- if (isWorkspaceUpload) {
221
- express.raw({ type: () => true, limit: '100mb' })(req, res, next);
174
+ const isWebhook = req.method === 'POST' && req.path.startsWith('/api/webhooks/');
175
+ if (isWorkspaceUpload || isWebhook) {
176
+ express.raw({ type: () => true, limit: isWebhook ? '5mb' : '100mb' })(req, res, next);
222
177
  return;
223
178
  }
224
179
  express.json({ limit: '20mb' })(req, res, next);
@@ -259,7 +214,7 @@ export async function startServer(options = {}) {
259
214
  .catch(() => { });
260
215
  }
261
216
  if (channelId === GLOBAL_CHANNEL_ID) {
262
- const snapshot = buildActiveRunsSnapshot();
217
+ const snapshot = activeRunsTracker.buildSnapshot();
263
218
  ensureEventId(snapshot);
264
219
  res.write(`data: ${JSON.stringify(snapshot)}\n\n`);
265
220
  }
@@ -429,38 +384,19 @@ export async function startServer(options = {}) {
429
384
  },
430
385
  };
431
386
  ensureEventId(stoppedEvent);
432
- persistLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId);
433
- sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
434
- sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
387
+ broadcastLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId, lifecycleDeps);
435
388
  res.json({ success: stopped });
436
389
  return;
437
390
  }
438
- const onEvent = async (chunk, state) => {
439
- const targetChannelId = state?.channelId || channelId;
440
- const targetThreadId = state?.threadId || threadId;
441
- const targetClientKey = getClientKey(targetChannelId, targetThreadId);
442
- if (chunk.type === 'agent:run:start') {
443
- activeRuns.set(getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId), {
444
- runId: chunk.data.runId,
445
- channelId: chunk.data.channelId,
446
- threadId: chunk.data.threadId,
447
- agentId: chunk.data.agentId,
448
- });
449
- }
450
- else if (chunk.type === 'agent:run:end') {
451
- activeRuns.delete(getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId));
452
- }
453
- else if (chunk.type === 'agent:run:stopped') {
454
- purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
455
- broadcastActiveRunsSnapshot();
456
- }
457
- sendToClientKey(targetClientKey, chunk);
458
- if (chunk.type === 'agent:run:start' ||
459
- chunk.type === 'agent:run:end' ||
460
- chunk.type === 'agent:run:stopped') {
461
- sendToClientKey(GLOBAL_CHANNEL_ID, chunk);
462
- }
463
- };
391
+ const onEvent = createRunEventHandler({
392
+ defaultChannelId: channelId,
393
+ defaultThreadId: threadId,
394
+ activeRuns: activeRunsTracker,
395
+ sendToClientKey,
396
+ getClientKey,
397
+ globalChannelId: GLOBAL_CHANNEL_ID,
398
+ onRunStopped: handleRunStopped,
399
+ });
464
400
  try {
465
401
  ensureEventId(event);
466
402
  const isUserConversationStart = event.type === 'agent:invoke' &&
@@ -490,20 +426,16 @@ export async function startServer(options = {}) {
490
426
  await runAgent({
491
427
  runId,
492
428
  agentId: target.agentId,
493
- event,
494
- channelId,
495
- threadId,
429
+ event: {
430
+ ...event,
431
+ meta: { ...event.meta, channelId, threadId },
432
+ },
496
433
  publicBaseUrl: resolvePublicBaseUrl(),
497
434
  onEvent,
498
435
  });
499
436
  res.sendStatus(200);
500
437
  }
501
438
  catch (error) {
502
- const message = error instanceof Error ? error.message : String(error);
503
- const isUnknownAgent = (error instanceof Error &&
504
- (error.code === 'AGENT_NOT_FOUND' ||
505
- error.message.includes('does not exist'))) ||
506
- message.includes('does not exist');
507
439
  console.error('[publish] Failed to dispatch event', {
508
440
  runId,
509
441
  channelId,
@@ -511,11 +443,82 @@ export async function startServer(options = {}) {
511
443
  eventType: event.type,
512
444
  error,
513
445
  });
514
- if (isUnknownAgent) {
515
- res.status(400).json({ error: message });
446
+ respondAgentDispatchError(res, error, 'Failed to process publish event');
447
+ }
448
+ });
449
+ const WEBHOOK_TIMEOUT_MS = Number(process.env.OPENBOT_WEBHOOK_TIMEOUT_MS ?? 30000);
450
+ app.post('/api/webhooks/:provider', async (req, res) => {
451
+ const provider = req.params.provider?.trim();
452
+ if (!provider || !/^[a-z0-9][a-z0-9_-]*$/i.test(provider)) {
453
+ res.status(400).json({ error: 'Invalid webhook provider' });
454
+ return;
455
+ }
456
+ const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
457
+ const runId = `run_${generateId()}`;
458
+ const agentId = await resolveWebhookAgentId(provider);
459
+ let httpResponse = null;
460
+ const onEvent = createRunEventHandler({
461
+ activeRuns: activeRunsTracker,
462
+ sendToClientKey,
463
+ getClientKey,
464
+ globalChannelId: GLOBAL_CHANNEL_ID,
465
+ onRunStopped: handleRunStopped,
466
+ onWebhookHttpResponse: (response) => {
467
+ httpResponse = response;
468
+ },
469
+ });
470
+ const event = {
471
+ type: 'action:webhook',
472
+ data: {
473
+ provider,
474
+ rawBody: rawBody.toString('base64'),
475
+ headers: pickWebhookHeaders(req),
476
+ query: req.query,
477
+ },
478
+ };
479
+ try {
480
+ ensureEventId(event);
481
+ console.log('runAgent', {
482
+ runId,
483
+ agentId,
484
+ event,
485
+ persistEvents: true,
486
+ publicBaseUrl: resolvePublicBaseUrl(),
487
+ onEvent,
488
+ });
489
+ const runPromise = runAgent({
490
+ runId,
491
+ agentId,
492
+ event,
493
+ persistEvents: true,
494
+ publicBaseUrl: resolvePublicBaseUrl(),
495
+ onEvent,
496
+ });
497
+ let timeoutId;
498
+ const timeoutPromise = new Promise((_, reject) => {
499
+ timeoutId = setTimeout(() => reject(new Error('webhook_timeout')), WEBHOOK_TIMEOUT_MS);
500
+ });
501
+ try {
502
+ await Promise.race([runPromise, timeoutPromise]);
503
+ }
504
+ finally {
505
+ if (timeoutId)
506
+ clearTimeout(timeoutId);
507
+ }
508
+ sendWebhookHttpResponse(res, httpResponse ?? { status: 200, body: {} });
509
+ }
510
+ catch (error) {
511
+ const isTimeout = error instanceof Error && error.message === 'webhook_timeout';
512
+ console.error('[webhook] Failed to process inbound webhook', {
513
+ provider,
514
+ agentId,
515
+ error,
516
+ });
517
+ if (isTimeout) {
518
+ res.status(504).json({ error: 'Webhook handler timed out' });
516
519
  return;
517
520
  }
518
- res.status(500).json({ error: 'Failed to process publish event' });
521
+ respondAgentDispatchError(res, error, 'Failed to process webhook');
519
522
  }
520
523
  });
521
524
  app.get('/api/state', async (req, res) => {
@@ -531,7 +534,7 @@ export async function startServer(options = {}) {
531
534
  const { channelId, threadId, agentId, runId } = getContext(req);
532
535
  // In-memory active runs (not persisted). Mirrors the initial SSE frame on __global__.
533
536
  if (channelId === GLOBAL_CHANNEL_ID && event.type === 'action:storage:get-active-runs') {
534
- const snapshot = buildActiveRunsSnapshot();
537
+ const snapshot = activeRunsTracker.buildSnapshot();
535
538
  ensureEventId(snapshot);
536
539
  res.json({ events: [snapshot] });
537
540
  return;
@@ -572,9 +575,10 @@ export async function startServer(options = {}) {
572
575
  await runAgent({
573
576
  runId,
574
577
  agentId: agentId || STATE_AGENT_ID,
575
- event,
576
- channelId,
577
- threadId,
578
+ event: {
579
+ ...event,
580
+ meta: { ...event.meta, channelId, threadId },
581
+ },
578
582
  persistEvents: false,
579
583
  publicBaseUrl: resolvePublicBaseUrl(),
580
584
  onEvent,
@@ -0,0 +1,81 @@
1
+ /** Webhook provider id for `POST /api/webhooks/slack`. */
2
+ export const SLACK_WEBHOOK_PROVIDER = 'slack';
3
+ const SLACK_CHANNEL_ID_PREFIX = 'slack-';
4
+ /** Map a Slack `channel` id to a stable OpenBot channel id. */
5
+ export function slackChannelToOpenBotChannelId(slackChannelId) {
6
+ const normalized = slackChannelId.trim();
7
+ if (!normalized) {
8
+ throw new Error('slackChannelId is required');
9
+ }
10
+ return `${SLACK_CHANNEL_ID_PREFIX}${normalized}`;
11
+ }
12
+ const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
13
+ const readString = (value) => {
14
+ if (typeof value !== 'string')
15
+ return undefined;
16
+ const trimmed = value.trim();
17
+ return trimmed || undefined;
18
+ };
19
+ /**
20
+ * Best-effort parse of a Slack Events API payload for routing and plugin handling.
21
+ * Returns `null` when the body is not JSON or not a recognized Slack envelope.
22
+ */
23
+ export function parseSlackWebhookPayload(rawBody) {
24
+ if (rawBody.length === 0)
25
+ return null;
26
+ let payload;
27
+ try {
28
+ payload = JSON.parse(rawBody.toString('utf-8'));
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ if (!isRecord(payload))
34
+ return null;
35
+ const envelopeType = readString(payload.type);
36
+ if (envelopeType === 'url_verification') {
37
+ const challenge = readString(payload.challenge);
38
+ if (!challenge)
39
+ return null;
40
+ return { kind: 'url_verification', challenge };
41
+ }
42
+ if (envelopeType !== 'event_callback')
43
+ return null;
44
+ const event = payload.event;
45
+ if (!isRecord(event))
46
+ return null;
47
+ const eventType = readString(event.type);
48
+ if (eventType !== 'message') {
49
+ return { kind: 'ignored', reason: `unsupported_event_type:${eventType || 'unknown'}` };
50
+ }
51
+ const subtype = readString(event.subtype);
52
+ if (subtype && subtype !== 'file_share') {
53
+ return { kind: 'ignored', reason: `message_subtype:${subtype}` };
54
+ }
55
+ if (readString(event.bot_id) || readString(event.bot_profile)) {
56
+ return { kind: 'ignored', reason: 'bot_message' };
57
+ }
58
+ const slackChannelId = readString(event.channel);
59
+ if (!slackChannelId) {
60
+ return { kind: 'ignored', reason: 'missing_channel' };
61
+ }
62
+ const text = readString(event.text) || (subtype === 'file_share' ? '[file shared]' : undefined);
63
+ if (!text) {
64
+ return { kind: 'ignored', reason: 'empty_text' };
65
+ }
66
+ return {
67
+ kind: 'message',
68
+ slackChannelId,
69
+ threadTs: readString(event.thread_ts),
70
+ messageTs: readString(event.ts),
71
+ userId: readString(event.user),
72
+ text,
73
+ };
74
+ }
75
+ /** Resolve the OpenBot thread id for a Slack message (`thread_ts` or root `ts`). */
76
+ export function slackMessageToOpenBotThreadId(message) {
77
+ return message.threadTs || message.messageTs;
78
+ }
79
+ export function slackChannelSpec(slackChannelId) {
80
+ return `# Slack ${slackChannelId}\n\nInbound messages from Slack channel \`${slackChannelId}\`.`;
81
+ }
@@ -0,0 +1,52 @@
1
+ import { ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
2
+ import { storageService } from '../plugins/storage/service.js';
3
+ /** Default agent when no `agents/<provider>/AGENT.md` exists. */
4
+ export const WEBHOOK_AGENT_FALLBACK_ID = ORCHESTRATOR_AGENT_ID;
5
+ export const isAgentNotFound = (error) => error instanceof Error &&
6
+ (error.code === 'AGENT_NOT_FOUND' ||
7
+ error.message.includes('does not exist'));
8
+ export function respondAgentDispatchError(res, error, fallbackMessage) {
9
+ const message = error instanceof Error ? error.message : String(error);
10
+ if (isAgentNotFound(error) || message.includes('does not exist')) {
11
+ res.status(400).json({ error: message });
12
+ return;
13
+ }
14
+ res.status(500).json({ error: fallbackMessage });
15
+ }
16
+ /**
17
+ * Routes `POST /api/webhooks/:provider` to agent `<provider>` when that agent exists,
18
+ * otherwise falls back to {@link WEBHOOK_AGENT_FALLBACK_ID}.
19
+ */
20
+ export async function resolveWebhookAgentId(provider) {
21
+ try {
22
+ await storageService.getAgentDetails({ agentId: provider });
23
+ return provider;
24
+ }
25
+ catch (error) {
26
+ if (isAgentNotFound(error)) {
27
+ return WEBHOOK_AGENT_FALLBACK_ID;
28
+ }
29
+ throw error;
30
+ }
31
+ }
32
+ /** Copy request headers for plugin-side signature verification. */
33
+ export function pickWebhookHeaders(req) {
34
+ return { ...req.headers };
35
+ }
36
+ export function sendWebhookHttpResponse(res, out) {
37
+ if (out.headers) {
38
+ for (const [key, value] of Object.entries(out.headers)) {
39
+ res.setHeader(key, value);
40
+ }
41
+ }
42
+ const status = out.status;
43
+ if (out.body === undefined) {
44
+ res.sendStatus(status);
45
+ return;
46
+ }
47
+ if (typeof out.body === 'string') {
48
+ res.status(status).send(out.body);
49
+ return;
50
+ }
51
+ res.status(status).json(out.body);
52
+ }