openbot 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app/cli.js CHANGED
@@ -16,7 +16,7 @@ function checkNodeVersion() {
16
16
  }
17
17
  }
18
18
  checkNodeVersion();
19
- program.name('openbot').description('OpenBot CLI').version('0.4.3');
19
+ program.name('openbot').description('OpenBot CLI').version('0.4.5');
20
20
  program
21
21
  .command('start')
22
22
  .description('Start the OpenBot harness')
@@ -0,0 +1,48 @@
1
+ import { ORCHESTRATOR_AGENT_ID } from './agent-ids.js';
2
+ import { storageService } from '../plugins/storage/service.js';
3
+ /** Thread `state.json` key for the sticky responding agent id. */
4
+ export const THREAD_RESPONDING_AGENT_ID_KEY = 'respondingAgentId';
5
+ const readBoundAgentId = (state) => {
6
+ if (!state || typeof state !== 'object')
7
+ return undefined;
8
+ const value = state[THREAD_RESPONDING_AGENT_ID_KEY];
9
+ if (typeof value !== 'string')
10
+ return undefined;
11
+ const trimmed = value.trim();
12
+ return trimmed || undefined;
13
+ };
14
+ /**
15
+ * Resolves which agent should handle a thread-scoped publish.
16
+ * Once `respondingAgentId` is stored on the thread, that value wins over request overrides.
17
+ */
18
+ export async function resolveRespondingAgentId(options) {
19
+ const { channelId, threadId, requestedAgentId, bindIfUnbound = false } = options;
20
+ const requested = requestedAgentId?.trim() || undefined;
21
+ const fallback = requested || ORCHESTRATOR_AGENT_ID;
22
+ if (!threadId) {
23
+ return { agentId: fallback, bound: false, overridden: false };
24
+ }
25
+ const details = await storageService.getThreadDetails({ channelId, threadId });
26
+ const bound = readBoundAgentId(details.state);
27
+ if (bound) {
28
+ const overridden = !!requested && requested !== bound;
29
+ if (overridden) {
30
+ console.warn('[publish] Ignoring agentId override; thread is bound to responding agent', {
31
+ threadId,
32
+ bound,
33
+ requested,
34
+ });
35
+ }
36
+ return { agentId: bound, bound: true, overridden };
37
+ }
38
+ if (!bindIfUnbound) {
39
+ return { agentId: fallback, bound: false, overridden: false };
40
+ }
41
+ await storageService.getAgentDetails({ agentId: fallback });
42
+ await storageService.patchThreadState({
43
+ channelId,
44
+ threadId,
45
+ state: { [THREAD_RESPONDING_AGENT_ID_KEY]: fallback },
46
+ });
47
+ return { agentId: fallback, bound: true, overridden: false };
48
+ }
@@ -16,6 +16,7 @@ import { storageService } from '../plugins/storage/service.js';
16
16
  import { buildWorkspaceFileUrl, getPublicBaseUrl, openChannelFileStream, } from '../plugins/storage/files.js';
17
17
  import { ensureEventId, openBotEventFromQuery } from './utils.js';
18
18
  import { abortRegistry, abortKey } from '../services/abort.js';
19
+ import { resolveRespondingAgentId } from './responding-agent.js';
19
20
  export async function startServer(options = {}) {
20
21
  const publishEventSchema = z
21
22
  .object({
@@ -116,19 +117,71 @@ export async function startServer(options = {}) {
116
117
  data: { channels },
117
118
  };
118
119
  };
120
+ const broadcastActiveRunsSnapshot = () => {
121
+ const snapshot = buildActiveRunsSnapshot();
122
+ ensureEventId(snapshot);
123
+ sendToClientKey(GLOBAL_CHANNEL_ID, snapshot);
124
+ };
125
+ const persistLifecycleEvent = (event, targetChannelId, targetThreadId) => {
126
+ ensureEventId(event);
127
+ storageService
128
+ .storeEvent({
129
+ channelId: targetChannelId,
130
+ threadId: targetThreadId,
131
+ event,
132
+ })
133
+ .catch((error) => {
134
+ console.error('[server] Failed to persist lifecycle event', {
135
+ type: event.type,
136
+ channelId: targetChannelId,
137
+ threadId: targetThreadId,
138
+ error,
139
+ });
140
+ });
141
+ };
119
142
  // Drop every tracked run for a channel/thread. A stop aborts the whole
120
143
  // chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
121
144
  // events can be swallowed when the parent run loop breaks on abort, leaving
122
- // orphaned entries that keep a channel falsely "active". Purging by
123
- // channel/thread guarantees the snapshot self-heals after a stop.
145
+ // orphaned entries that keep a channel falsely "active". Emit explicit
146
+ // `agent:run:end` for each purged run and refresh the global snapshot so
147
+ // clients stay in sync even when the parent harness stops yielding.
124
148
  const purgeActiveRunsForThread = (channelId, threadId) => {
125
149
  const target = threadId || undefined;
150
+ const removed = [];
126
151
  for (const [key, run] of activeRuns) {
127
152
  if (run.channelId === channelId && (run.threadId || undefined) === target) {
153
+ removed.push(run);
128
154
  activeRuns.delete(key);
129
155
  }
130
156
  }
157
+ for (const run of removed) {
158
+ const endEvent = {
159
+ type: 'agent:run:end',
160
+ data: {
161
+ runId: run.runId,
162
+ agentId: run.agentId,
163
+ channelId: run.channelId,
164
+ threadId: run.threadId,
165
+ },
166
+ };
167
+ ensureEventId(endEvent);
168
+ persistLifecycleEvent(endEvent, run.channelId, run.threadId);
169
+ sendToClientKey(getClientKey(run.channelId, run.threadId), endEvent);
170
+ sendToClientKey(GLOBAL_CHANNEL_ID, endEvent);
171
+ }
131
172
  };
173
+ // Support for Chrome's Private Network Access (PNA)
174
+ // https://developer.chrome.com/blog/private-network-access-preflight/
175
+ app.use((req, res, next) => {
176
+ if (req.headers['access-control-request-private-network'] === 'true') {
177
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
178
+ }
179
+ // If it's a preflight request, we should also ensure the Vary header is set
180
+ if (req.method === 'OPTIONS') {
181
+ res.setHeader('Vary', 'Access-Control-Request-Private-Network');
182
+ }
183
+ next();
184
+ });
132
185
  app.use(cors());
133
186
  const resolvePublicBaseUrl = () => getPublicBaseUrl(PORT, config.publicUrl);
134
187
  app.use((req, res, next) => {
@@ -316,19 +369,38 @@ export async function startServer(options = {}) {
316
369
  const data = (event.data ?? {});
317
370
  const targetChannelId = data.channelId || channelId;
318
371
  const targetThreadId = data.threadId || threadId;
372
+ let resolvedStopAgentId = data.agentId || agentId || ORCHESTRATOR_AGENT_ID;
373
+ try {
374
+ const resolved = await resolveRespondingAgentId({
375
+ channelId: targetChannelId,
376
+ threadId: targetThreadId,
377
+ requestedAgentId: data.agentId || agentId,
378
+ });
379
+ resolvedStopAgentId = resolved.agentId;
380
+ }
381
+ catch (error) {
382
+ console.warn('[publish] Failed to resolve responding agent for stop request', {
383
+ channelId: targetChannelId,
384
+ threadId: targetThreadId,
385
+ error,
386
+ });
387
+ }
319
388
  const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
320
389
  purgeActiveRunsForThread(targetChannelId, targetThreadId);
390
+ // Resync global clients even when nothing was tracked server-side.
391
+ broadcastActiveRunsSnapshot();
321
392
  const stoppedEvent = {
322
393
  type: 'agent:run:stopped',
323
394
  data: {
324
395
  runId: data.runId || runId,
325
- agentId: data.agentId || agentId || ORCHESTRATOR_AGENT_ID,
396
+ agentId: resolvedStopAgentId,
326
397
  channelId: targetChannelId,
327
398
  threadId: targetThreadId,
328
399
  reason: data.reason,
329
400
  },
330
401
  };
331
402
  ensureEventId(stoppedEvent);
403
+ persistLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId);
332
404
  sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
333
405
  sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
334
406
  res.json({ success: stopped });
@@ -351,6 +423,7 @@ export async function startServer(options = {}) {
351
423
  }
352
424
  else if (chunk.type === 'agent:run:stopped') {
353
425
  purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
426
+ broadcastActiveRunsSnapshot();
354
427
  }
355
428
  sendToClientKey(targetClientKey, chunk);
356
429
  if (chunk.type === 'agent:run:start' ||
@@ -361,9 +434,16 @@ export async function startServer(options = {}) {
361
434
  };
362
435
  try {
363
436
  ensureEventId(event);
437
+ const bindIfUnbound = event.type === 'agent:invoke';
438
+ const resolved = await resolveRespondingAgentId({
439
+ channelId,
440
+ threadId,
441
+ requestedAgentId: agentId,
442
+ bindIfUnbound,
443
+ });
364
444
  await runAgent({
365
445
  runId,
366
- agentId: agentId || ORCHESTRATOR_AGENT_ID,
446
+ agentId: resolved.agentId,
367
447
  event,
368
448
  channelId,
369
449
  threadId,
@@ -373,6 +453,11 @@ export async function startServer(options = {}) {
373
453
  res.sendStatus(200);
374
454
  }
375
455
  catch (error) {
456
+ const message = error instanceof Error ? error.message : String(error);
457
+ const isUnknownAgent = (error instanceof Error &&
458
+ (error.code === 'AGENT_NOT_FOUND' ||
459
+ error.message.includes('does not exist'))) ||
460
+ message.includes('does not exist');
376
461
  console.error('[publish] Failed to dispatch event', {
377
462
  runId,
378
463
  channelId,
@@ -380,6 +465,10 @@ export async function startServer(options = {}) {
380
465
  eventType: event.type,
381
466
  error,
382
467
  });
468
+ if (isUnknownAgent) {
469
+ res.status(400).json({ error: message });
470
+ return;
471
+ }
383
472
  res.status(500).json({ error: 'Failed to process publish event' });
384
473
  }
385
474
  });
@@ -394,6 +483,13 @@ export async function startServer(options = {}) {
394
483
  return;
395
484
  }
396
485
  const { channelId, threadId, agentId, runId } = getContext(req);
486
+ // In-memory active runs (not persisted). Mirrors the initial SSE frame on __global__.
487
+ if (channelId === GLOBAL_CHANNEL_ID && event.type === 'action:storage:get-active-runs') {
488
+ const snapshot = buildActiveRunsSnapshot();
489
+ ensureEventId(snapshot);
490
+ res.json({ events: [snapshot] });
491
+ return;
492
+ }
397
493
  if (event.type === 'action:storage:serve-file') {
398
494
  const filePath = event.data?.path;
399
495
  if (!channelId?.trim()) {
@@ -614,7 +614,7 @@ export const storageService = {
614
614
  id: threadId,
615
615
  name: threadName || threadId,
616
616
  channelId,
617
- state,
617
+ state: (isRecord(state) ? state : {}),
618
618
  };
619
619
  },
620
620
  getChannelDetails: async ({ channelId }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbot",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
package/src/app/cli.ts CHANGED
@@ -25,7 +25,7 @@ function checkNodeVersion() {
25
25
 
26
26
  checkNodeVersion();
27
27
 
28
- program.name('openbot').description('OpenBot CLI').version('0.4.3');
28
+ program.name('openbot').description('OpenBot CLI').version('0.4.5');
29
29
 
30
30
  program
31
31
  .command('start')
@@ -0,0 +1,74 @@
1
+ import { ORCHESTRATOR_AGENT_ID } from './agent-ids.js';
2
+ import { storageService } from '../plugins/storage/service.js';
3
+
4
+ /** Thread `state.json` key for the sticky responding agent id. */
5
+ export const THREAD_RESPONDING_AGENT_ID_KEY = 'respondingAgentId';
6
+
7
+ export type ResolveRespondingAgentOptions = {
8
+ channelId: string;
9
+ threadId?: string;
10
+ requestedAgentId?: string;
11
+ /** When true, persist `respondingAgentId` on the first unbound thread touch. */
12
+ bindIfUnbound?: boolean;
13
+ };
14
+
15
+ export type ResolveRespondingAgentResult = {
16
+ agentId: string;
17
+ /** True when the thread already had or now has a persisted responding agent. */
18
+ bound: boolean;
19
+ /** True when the request asked for a different agent than the bound one. */
20
+ overridden: boolean;
21
+ };
22
+
23
+ const readBoundAgentId = (state: unknown): string | undefined => {
24
+ if (!state || typeof state !== 'object') return undefined;
25
+ const value = (state as Record<string, unknown>)[THREAD_RESPONDING_AGENT_ID_KEY];
26
+ if (typeof value !== 'string') return undefined;
27
+ const trimmed = value.trim();
28
+ return trimmed || undefined;
29
+ };
30
+
31
+ /**
32
+ * Resolves which agent should handle a thread-scoped publish.
33
+ * Once `respondingAgentId` is stored on the thread, that value wins over request overrides.
34
+ */
35
+ export async function resolveRespondingAgentId(
36
+ options: ResolveRespondingAgentOptions,
37
+ ): Promise<ResolveRespondingAgentResult> {
38
+ const { channelId, threadId, requestedAgentId, bindIfUnbound = false } = options;
39
+ const requested = requestedAgentId?.trim() || undefined;
40
+ const fallback = requested || ORCHESTRATOR_AGENT_ID;
41
+
42
+ if (!threadId) {
43
+ return { agentId: fallback, bound: false, overridden: false };
44
+ }
45
+
46
+ const details = await storageService.getThreadDetails({ channelId, threadId });
47
+ const bound = readBoundAgentId(details.state);
48
+
49
+ if (bound) {
50
+ const overridden = !!requested && requested !== bound;
51
+ if (overridden) {
52
+ console.warn('[publish] Ignoring agentId override; thread is bound to responding agent', {
53
+ threadId,
54
+ bound,
55
+ requested,
56
+ });
57
+ }
58
+ return { agentId: bound, bound: true, overridden };
59
+ }
60
+
61
+ if (!bindIfUnbound) {
62
+ return { agentId: fallback, bound: false, overridden: false };
63
+ }
64
+
65
+ await storageService.getAgentDetails({ agentId: fallback });
66
+
67
+ await storageService.patchThreadState({
68
+ channelId,
69
+ threadId,
70
+ state: { [THREAD_RESPONDING_AGENT_ID_KEY]: fallback },
71
+ });
72
+
73
+ return { agentId: fallback, bound: true, overridden: false };
74
+ }
package/src/app/server.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  } from '../plugins/storage/files.js';
22
22
  import { ensureEventId, openBotEventFromQuery } from './utils.js';
23
23
  import { abortRegistry, abortKey } from '../services/abort.js';
24
+ import { resolveRespondingAgentId } from './responding-agent.js';
24
25
 
25
26
  type Bucket = { channelId: string; threadId?: string; activeCount: number; agentIds: Set<string> };
26
27
 
@@ -150,20 +151,86 @@ export async function startServer(options: ServerOptions = {}) {
150
151
  };
151
152
  };
152
153
 
154
+ const broadcastActiveRunsSnapshot = (): void => {
155
+ const snapshot = buildActiveRunsSnapshot();
156
+ ensureEventId(snapshot);
157
+ sendToClientKey(GLOBAL_CHANNEL_ID, snapshot);
158
+ };
159
+
160
+ const persistLifecycleEvent = (
161
+ event: OpenBotEvent,
162
+ targetChannelId: string,
163
+ targetThreadId?: string,
164
+ ): void => {
165
+ ensureEventId(event);
166
+ storageService
167
+ .storeEvent({
168
+ channelId: targetChannelId,
169
+ threadId: targetThreadId,
170
+ event,
171
+ })
172
+ .catch((error) => {
173
+ console.error('[server] Failed to persist lifecycle event', {
174
+ type: event.type,
175
+ channelId: targetChannelId,
176
+ threadId: targetThreadId,
177
+ error,
178
+ });
179
+ });
180
+ };
181
+
153
182
  // Drop every tracked run for a channel/thread. A stop aborts the whole
154
183
  // chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
155
184
  // 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.
185
+ // orphaned entries that keep a channel falsely "active". Emit explicit
186
+ // `agent:run:end` for each purged run and refresh the global snapshot so
187
+ // clients stay in sync even when the parent harness stops yielding.
158
188
  const purgeActiveRunsForThread = (channelId: string, threadId?: string): void => {
159
189
  const target = threadId || undefined;
190
+ const removed: Array<{
191
+ runId: string;
192
+ channelId: string;
193
+ threadId?: string;
194
+ agentId: string;
195
+ }> = [];
196
+
160
197
  for (const [key, run] of activeRuns) {
161
198
  if (run.channelId === channelId && (run.threadId || undefined) === target) {
199
+ removed.push(run);
162
200
  activeRuns.delete(key);
163
201
  }
164
202
  }
203
+
204
+ for (const run of removed) {
205
+ const endEvent: OpenBotEvent = {
206
+ type: 'agent:run:end',
207
+ data: {
208
+ runId: run.runId,
209
+ agentId: run.agentId,
210
+ channelId: run.channelId,
211
+ threadId: run.threadId,
212
+ },
213
+ } as OpenBotEvent;
214
+ ensureEventId(endEvent);
215
+ persistLifecycleEvent(endEvent, run.channelId, run.threadId);
216
+ sendToClientKey(getClientKey(run.channelId, run.threadId), endEvent);
217
+ sendToClientKey(GLOBAL_CHANNEL_ID, endEvent);
218
+ }
165
219
  };
166
220
 
221
+ // Support for Chrome's Private Network Access (PNA)
222
+ // https://developer.chrome.com/blog/private-network-access-preflight/
223
+ app.use((req, res, next) => {
224
+ if (req.headers['access-control-request-private-network'] === 'true') {
225
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
226
+ }
227
+ // If it's a preflight request, we should also ensure the Vary header is set
228
+ if (req.method === 'OPTIONS') {
229
+ res.setHeader('Vary', 'Access-Control-Request-Private-Network');
230
+ }
231
+ next();
232
+ });
233
+
167
234
  app.use(cors());
168
235
 
169
236
  const resolvePublicBaseUrl = () => getPublicBaseUrl(PORT, config.publicUrl);
@@ -388,20 +455,38 @@ export async function startServer(options: ServerOptions = {}) {
388
455
  };
389
456
  const targetChannelId = data.channelId || channelId;
390
457
  const targetThreadId = data.threadId || threadId;
458
+ let resolvedStopAgentId = data.agentId || agentId || ORCHESTRATOR_AGENT_ID;
459
+ try {
460
+ const resolved = await resolveRespondingAgentId({
461
+ channelId: targetChannelId,
462
+ threadId: targetThreadId,
463
+ requestedAgentId: data.agentId || agentId,
464
+ });
465
+ resolvedStopAgentId = resolved.agentId;
466
+ } catch (error) {
467
+ console.warn('[publish] Failed to resolve responding agent for stop request', {
468
+ channelId: targetChannelId,
469
+ threadId: targetThreadId,
470
+ error,
471
+ });
472
+ }
391
473
  const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
392
474
  purgeActiveRunsForThread(targetChannelId, targetThreadId);
475
+ // Resync global clients even when nothing was tracked server-side.
476
+ broadcastActiveRunsSnapshot();
393
477
 
394
478
  const stoppedEvent: OpenBotEvent = {
395
479
  type: 'agent:run:stopped',
396
480
  data: {
397
481
  runId: data.runId || runId,
398
- agentId: data.agentId || agentId || ORCHESTRATOR_AGENT_ID,
482
+ agentId: resolvedStopAgentId,
399
483
  channelId: targetChannelId,
400
484
  threadId: targetThreadId,
401
485
  reason: data.reason,
402
486
  },
403
487
  } as OpenBotEvent;
404
488
  ensureEventId(stoppedEvent);
489
+ persistLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId);
405
490
  sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
406
491
  sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
407
492
 
@@ -430,6 +515,7 @@ export async function startServer(options: ServerOptions = {}) {
430
515
  );
431
516
  } else if (chunk.type === 'agent:run:stopped') {
432
517
  purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
518
+ broadcastActiveRunsSnapshot();
433
519
  }
434
520
 
435
521
  sendToClientKey(targetClientKey, chunk);
@@ -446,9 +532,17 @@ export async function startServer(options: ServerOptions = {}) {
446
532
  try {
447
533
  ensureEventId(event);
448
534
 
535
+ const bindIfUnbound = event.type === 'agent:invoke';
536
+ const resolved = await resolveRespondingAgentId({
537
+ channelId,
538
+ threadId,
539
+ requestedAgentId: agentId,
540
+ bindIfUnbound,
541
+ });
542
+
449
543
  await runAgent({
450
544
  runId,
451
- agentId: agentId || ORCHESTRATOR_AGENT_ID,
545
+ agentId: resolved.agentId,
452
546
  event,
453
547
  channelId,
454
548
  threadId,
@@ -457,6 +551,13 @@ export async function startServer(options: ServerOptions = {}) {
457
551
  });
458
552
  res.sendStatus(200);
459
553
  } catch (error) {
554
+ const message = error instanceof Error ? error.message : String(error);
555
+ const isUnknownAgent =
556
+ (error instanceof Error &&
557
+ ((error as Error & { code?: string }).code === 'AGENT_NOT_FOUND' ||
558
+ error.message.includes('does not exist'))) ||
559
+ message.includes('does not exist');
560
+
460
561
  console.error('[publish] Failed to dispatch event', {
461
562
  runId,
462
563
  channelId,
@@ -464,6 +565,12 @@ export async function startServer(options: ServerOptions = {}) {
464
565
  eventType: event.type,
465
566
  error,
466
567
  });
568
+
569
+ if (isUnknownAgent) {
570
+ res.status(400).json({ error: message });
571
+ return;
572
+ }
573
+
467
574
  res.status(500).json({ error: 'Failed to process publish event' });
468
575
  }
469
576
  });
@@ -480,6 +587,14 @@ export async function startServer(options: ServerOptions = {}) {
480
587
 
481
588
  const { channelId, threadId, agentId, runId } = getContext(req);
482
589
 
590
+ // In-memory active runs (not persisted). Mirrors the initial SSE frame on __global__.
591
+ if (channelId === GLOBAL_CHANNEL_ID && event.type === 'action:storage:get-active-runs') {
592
+ const snapshot = buildActiveRunsSnapshot();
593
+ ensureEventId(snapshot);
594
+ res.json({ events: [snapshot] });
595
+ return;
596
+ }
597
+
483
598
  if (event.type === 'action:storage:serve-file') {
484
599
  const filePath = (event.data as { path?: string })?.path;
485
600
  if (!channelId?.trim()) {
package/src/app/types.ts CHANGED
@@ -141,6 +141,10 @@ export type GetEventsResultEvent = BaseEvent & {
141
141
  };
142
142
  };
143
143
 
144
+ export type GetActiveRunsEvent = BaseEvent & {
145
+ type: 'action:storage:get-active-runs';
146
+ };
147
+
144
148
  export type GetAgentDetailsEvent = BaseEvent & {
145
149
  type: 'action:storage:get-agent-details';
146
150
  data: {
@@ -1059,6 +1063,7 @@ export type OpenBotEvent =
1059
1063
  | DeleteAgentResultEvent
1060
1064
  | GetEventsEvent
1061
1065
  | GetEventsResultEvent
1066
+ | GetActiveRunsEvent
1062
1067
  | StreamThreadEvent
1063
1068
  | GetVariablesEvent
1064
1069
  | GetVariablesResultEvent
@@ -24,6 +24,7 @@ import {
24
24
  PluginDescriptor,
25
25
  Thread,
26
26
  ThreadDetails,
27
+ ThreadState,
27
28
  } from '../../services/plugins/domain.js';
28
29
  import type { PluginRef } from '../../services/plugins/types.js';
29
30
  import { openbotPlugin } from '../openbot/index.js';
@@ -771,7 +772,7 @@ export const storageService = {
771
772
  id: threadId,
772
773
  name: threadName || threadId,
773
774
  channelId,
774
- state,
775
+ state: (isRecord(state) ? state : {}) as ThreadState,
775
776
  };
776
777
  },
777
778
  getChannelDetails: async ({ channelId }: { channelId: string }): Promise<ChannelDetails> => {
@@ -81,11 +81,25 @@ export type Thread = {
81
81
  hasUnseenMessages?: boolean;
82
82
  };
83
83
 
84
+ /** Persisted thread `state.json` fields (additional keys are allowed). */
85
+ export type ThreadState = {
86
+ name?: string;
87
+ /** Sticky agent id for this thread (`system` = orchestrator). Set once, then enforced on publish. */
88
+ respondingAgentId?: string;
89
+ pendingToolCallIds?: string[];
90
+ usage?: {
91
+ promptTokens?: number;
92
+ completionTokens?: number;
93
+ totalTokens?: number;
94
+ };
95
+ [key: string]: unknown;
96
+ };
97
+
84
98
  export type ThreadDetails = {
85
99
  id: string;
86
100
  name: string;
87
101
  channelId: string;
88
- state: unknown;
102
+ state: ThreadState;
89
103
  };
90
104
 
91
105
  export type ChannelDetails = {