openbot 0.4.4 → 0.4.6

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.
@@ -54,64 +54,22 @@ export const pluginManagerPlugin = {
54
54
  });
55
55
  builder.on('action:channel:install', async function* (event) {
56
56
  try {
57
- const { channelId: instanceId, name: templateName, participants: customParticipants, initialState: customInitialState, } = event.data;
58
- const { agents: marketplaceAgents, channels } = await resolveMarketplaceRegistry();
57
+ const { channelId: instanceId, name: templateName, initialState: customInitialState, } = event.data;
58
+ const { channels } = await resolveMarketplaceRegistry();
59
59
  // Try to find the template by ID or Name
60
60
  const channelListing = channels.find((c) => c.id === instanceId) ||
61
61
  channels.find((c) => c.name === templateName);
62
62
  const channelId = instanceId;
63
- const participants = customParticipants || channelListing?.participants || [];
64
63
  const initialState = {
65
64
  ...(channelListing?.initialState || {}),
66
65
  ...(customInitialState || {}),
67
66
  };
68
67
  const spec = channelListing?.spec || '';
69
- // 1. Auto-install participant agents if missing
70
- for (const agentId of participants) {
71
- const existingAgents = await storage.getAgents();
72
- if (existingAgents.some((a) => a.id === agentId)) {
73
- continue;
74
- }
75
- // Not found locally, look in marketplace
76
- const agentListing = marketplaceAgents.find((a) => a.id === agentId);
77
- if (agentListing) {
78
- console.log(`[plugin-manager] Auto-installing agent ${agentId} for channel ${channelId}`);
79
- // Install plugins for this agent
80
- for (const ref of agentListing.plugins) {
81
- const installed = await pluginService.isInstalled(ref.id);
82
- if (!installed &&
83
- ref.id.includes('/') === false &&
84
- ref.id.includes('-plugin-') === false) {
85
- continue;
86
- }
87
- if (!installed) {
88
- try {
89
- await pluginService.install({ packageName: ref.id });
90
- }
91
- catch (err) {
92
- console.warn(`[plugins] Failed to pre-install plugin ${ref.id}`, err);
93
- }
94
- }
95
- }
96
- // Create the agent
97
- await storage.createAgent({
98
- agentId: agentListing.id,
99
- name: agentListing.name,
100
- description: agentListing.description,
101
- image: agentListing.image,
102
- instructions: agentListing.instructions,
103
- plugins: agentListing.plugins,
104
- });
105
- }
106
- }
107
68
  // 2. Create the channel
108
69
  await storage.createChannel({
109
70
  channelId,
110
71
  spec,
111
- initialState: {
112
- ...initialState,
113
- participants,
114
- },
72
+ initialState,
115
73
  });
116
74
  const channelUrl = `/channels/${channelId}`;
117
75
  yield {
@@ -38,15 +38,10 @@ const storageToolDefinitions = {
38
38
  .optional()
39
39
  .describe('Markdown content for the channel specification (SPEC.md). Use for goals and rules.'),
40
40
  cwd: z.string().optional().describe('Current working directory for the channel.'),
41
- participants: z
42
- .array(z.string())
43
- .optional()
44
- .describe('List of agent IDs that are participants in this channel. When a user tags an agent (e.g. @agent-id), you should ensure they are added to this list if they are not already there.'),
45
41
  })
46
42
  .refine((value) => value.state !== undefined ||
47
43
  value.spec !== undefined ||
48
- value.cwd !== undefined ||
49
- value.participants !== undefined, { message: 'Provide at least one of state, spec, cwd, or participants.' }),
44
+ value.cwd !== undefined, { message: 'Provide at least one of state, spec, or cwd.' }),
50
45
  },
51
46
  patch_thread_details: {
52
47
  description: 'Patch current thread details (state).',
@@ -125,7 +120,7 @@ export const storagePlugin = {
125
120
  };
126
121
  });
127
122
  builder.on('action:create_channel', async function* (event, context) {
128
- const { channelId, spec, initialState, cwd, participants } = event.data;
123
+ const { channelId, spec, initialState, cwd } = event.data;
129
124
  const rawChannelId = (channelId || '').trim();
130
125
  const channelSpec = typeof spec === 'string' ? spec : '';
131
126
  const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
@@ -139,15 +134,6 @@ export const storagePlugin = {
139
134
  }
140
135
  const channelUrl = `/channels/${rawChannelId}`;
141
136
  const mergedInitial = { ...(initialState || {}) };
142
- if (participants !== undefined) {
143
- const normalized = Array.isArray(participants)
144
- ? participants
145
- .filter((x) => typeof x === 'string')
146
- .map((s) => s.trim())
147
- .filter(Boolean)
148
- : [];
149
- mergedInitial.participants = normalized;
150
- }
151
137
  try {
152
138
  await storage.createChannel({
153
139
  channelId: rawChannelId,
@@ -232,18 +218,6 @@ export const storagePlugin = {
232
218
  patch.cwd = data.cwd.trim();
233
219
  updatedFields.push('cwd');
234
220
  }
235
- if (data.participants !== undefined) {
236
- if (Array.isArray(data.participants)) {
237
- patch.participants = data.participants
238
- .filter((x) => typeof x === 'string')
239
- .map((s) => s.trim())
240
- .filter(Boolean);
241
- }
242
- else {
243
- patch.participants = [];
244
- }
245
- updatedFields.push('participants');
246
- }
247
221
  try {
248
222
  if (updatedFields.length > 0) {
249
223
  await storage.patchChannelState({ channelId: targetChannelId, state: patch });
@@ -293,19 +267,6 @@ export const storagePlugin = {
293
267
  });
294
268
  updatedFields.push('cwd');
295
269
  }
296
- if (data.participants !== undefined) {
297
- const normalized = Array.isArray(data.participants)
298
- ? data.participants
299
- .filter((x) => typeof x === 'string')
300
- .map((s) => s.trim())
301
- .filter(Boolean)
302
- : [];
303
- await storage.patchChannelState({
304
- channelId: context.state.channelId,
305
- state: { participants: normalized },
306
- });
307
- updatedFields.push('participants');
308
- }
309
270
  context.state.channelDetails = await storage.getChannelDetails({
310
271
  channelId: context.state.channelId,
311
272
  });
@@ -340,18 +340,16 @@ const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArr
340
340
  /** Display-oriented fields persisted in a channel's `state.json`. */
341
341
  const readChannelStateFileFields = (parsed) => {
342
342
  if (!isRecord(parsed)) {
343
- return { participants: [] };
343
+ return {};
344
344
  }
345
345
  const name = typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name.trim() : undefined;
346
346
  const cwd = typeof parsed.cwd === 'string' ? parsed.cwd : undefined;
347
- const participants = [];
348
- if (Array.isArray(parsed.participants)) {
349
- for (const x of parsed.participants) {
350
- if (typeof x === 'string' && x.trim())
351
- participants.push(x.trim());
352
- }
353
- }
354
- return { name, cwd, participants };
347
+ return { name, cwd };
348
+ };
349
+ const isChannelProvisioned = async (channelId) => {
350
+ const statePath = `${getConversationDir(channelId)}/state.json`;
351
+ const state = await readJsonFile(statePath, {});
352
+ return !!readChannelStateFileFields(state).cwd;
355
353
  };
356
354
  /**
357
355
  * Parse the `plugins:` array from AGENT.md frontmatter. Each entry must have an
@@ -412,23 +410,23 @@ export const storageService = {
412
410
  const statePath = path.join(channelDir, 'state.json');
413
411
  let cwd;
414
412
  let displayName = name;
415
- let participants = [];
416
413
  try {
417
414
  const parsed = await readJsonFile(statePath, {});
418
415
  const fields = readChannelStateFileFields(parsed);
419
416
  cwd = fields.cwd;
420
417
  displayName = fields.name ?? name;
421
- participants = fields.participants;
422
418
  }
423
419
  catch {
424
420
  // ignore
425
421
  }
422
+ if (!cwd) {
423
+ return null;
424
+ }
426
425
  const channel = {
427
426
  id: name,
428
427
  name: displayName,
429
428
  description: '',
430
429
  cwd,
431
- participants,
432
430
  createdAt: stats.birthtime,
433
431
  updatedAt: stats.mtime,
434
432
  };
@@ -452,7 +450,9 @@ export const storageService = {
452
450
  }
453
451
  return channel;
454
452
  }));
455
- return channels.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
453
+ return channels
454
+ .filter((channel) => channel !== null)
455
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
456
456
  },
457
457
  createChannel: async ({ channelId, spec, initialState, cwd, }) => {
458
458
  const normalizedChannelId = channelId.trim();
@@ -486,6 +486,44 @@ export const storageService = {
486
486
  `# ${normalizedChannelId}\n\n`);
487
487
  await writeJsonFileAtomically(statePath, finalState);
488
488
  },
489
+ ensureChannel: async ({ channelId, spec, initialState, cwd, }) => {
490
+ const normalizedChannelId = channelId.trim();
491
+ if (!normalizedChannelId) {
492
+ throw new Error('channelId is required');
493
+ }
494
+ const channelDir = getConversationDir(normalizedChannelId);
495
+ const specPath = `${channelDir}/SPEC.md`;
496
+ const statePath = `${channelDir}/state.json`;
497
+ const existingState = await readJsonFile(statePath, {});
498
+ const existingFields = readChannelStateFileFields(existingState);
499
+ if (existingFields.cwd) {
500
+ await fs.mkdir(resolvePath(existingFields.cwd), { recursive: true });
501
+ return;
502
+ }
503
+ const finalState = {
504
+ ...existingState,
505
+ ...(initialState || {}),
506
+ };
507
+ const rawCwd = (typeof cwd === 'string' && cwd.trim()) ||
508
+ (typeof finalState.cwd === 'string' && finalState.cwd.trim()) ||
509
+ getDefaultChannelCwd(normalizedChannelId);
510
+ const resolvedCwd = resolvePath(rawCwd);
511
+ finalState.cwd = resolvedCwd;
512
+ await fs.mkdir(resolvedCwd, { recursive: true });
513
+ await fs.mkdir(channelDir, { recursive: true });
514
+ try {
515
+ await fs.access(specPath);
516
+ }
517
+ catch (error) {
518
+ if (error?.code === 'ENOENT') {
519
+ await fs.writeFile(specPath, spec?.trim() || `# ${normalizedChannelId}\n\n`);
520
+ }
521
+ else {
522
+ throw error;
523
+ }
524
+ }
525
+ await writeJsonFileAtomically(statePath, finalState);
526
+ },
489
527
  deleteChannel: async ({ channelId }) => {
490
528
  const normalizedChannelId = channelId.trim();
491
529
  if (!normalizedChannelId) {
@@ -614,7 +652,7 @@ export const storageService = {
614
652
  id: threadId,
615
653
  name: threadName || threadId,
616
654
  channelId,
617
- state,
655
+ state: (isRecord(state) ? state : {}),
618
656
  };
619
657
  },
620
658
  getChannelDetails: async ({ channelId }) => {
@@ -648,7 +686,6 @@ export const storageService = {
648
686
  spec,
649
687
  state,
650
688
  cwd,
651
- participants: diskFields.participants,
652
689
  };
653
690
  details.threads = await storageService.getThreads({ channelId });
654
691
  return details;
@@ -1030,6 +1067,9 @@ export const storageService = {
1030
1067
  },
1031
1068
  storeEvent: async ({ channelId, threadId, event, }) => {
1032
1069
  try {
1070
+ if (!(await isChannelProvisioned(channelId))) {
1071
+ return;
1072
+ }
1033
1073
  const threadDir = getConversationDir(channelId, threadId);
1034
1074
  if (threadId) {
1035
1075
  let exists = false;
@@ -74,20 +74,16 @@ export function parseMarketplaceRegistryJson(data) {
74
74
  const id = item.id;
75
75
  const name = item.name;
76
76
  const description = item.description;
77
- const participants = item.participants;
78
77
  if (typeof id !== 'string' || !id)
79
78
  throw new Error(`channels[${i}].id must be a non-empty string`);
80
79
  if (typeof name !== 'string')
81
80
  throw new Error(`channels[${i}].name must be a string`);
82
81
  if (typeof description !== 'string')
83
82
  throw new Error(`channels[${i}].description must be a string`);
84
- if (!Array.isArray(participants))
85
- throw new Error(`channels[${i}].participants must be an array`);
86
83
  const listing = {
87
84
  id,
88
85
  name,
89
86
  description,
90
- participants: participants.filter((p) => typeof p === 'string'),
91
87
  };
92
88
  if (typeof item.image === 'string')
93
89
  listing.image = item.image;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbot",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@ai-sdk/anthropic": "^3.0.33",
19
+ "@ai-sdk/google": "^3.0.82",
19
20
  "@ai-sdk/openai": "^3.0.13",
20
21
  "@anthropic-ai/claude-agent-sdk": "^0.2.138",
21
22
  "@types/cors": "^2.8.19",
@@ -0,0 +1,5 @@
1
+ /** Default channel when requests omit channelId (general-purpose conversation). */
2
+ export const UNCATEGORIZED_CHANNEL_ID = 'uncategorized';
3
+
4
+ export const DEFAULT_UNCATEGORIZED_SPEC =
5
+ '# Uncategorized\n\nGeneral-purpose channel for conversations without a dedicated channel.';
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.4');
28
+ program.name('openbot').description('OpenBot CLI').version('0.4.6');
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,11 @@ 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';
25
+ import {
26
+ DEFAULT_UNCATEGORIZED_SPEC,
27
+ UNCATEGORIZED_CHANNEL_ID,
28
+ } from './channel-ids.js';
24
29
 
25
30
  type Bucket = { channelId: string; threadId?: string; activeCount: number; agentIds: Set<string> };
26
31
 
@@ -64,9 +69,18 @@ export async function startServer(options: ServerOptions = {}) {
64
69
  storageService.getAgents().catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
65
70
  storageService.getPlugins().catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
66
71
 
72
+ const getRawChannelId = (req: express.Request): string | undefined => {
73
+ const raw =
74
+ req.get('x-openbot-channel-id') ||
75
+ req.query.channelId ||
76
+ (req.body && req.body.channelId);
77
+ if (typeof raw !== 'string') return undefined;
78
+ const trimmed = raw.trim();
79
+ return trimmed || undefined;
80
+ };
81
+
67
82
  const getContext = (req: express.Request) => {
68
- const channelId =
69
- req.get('x-openbot-channel-id') || req.query.channelId || (req.body && req.body.channelId);
83
+ const rawChannelId = getRawChannelId(req);
70
84
  const threadId =
71
85
  req.get('x-openbot-thread-id') || req.query.threadId || (req.body && req.body.threadId);
72
86
  const agentId =
@@ -82,7 +96,8 @@ export async function startServer(options: ServerOptions = {}) {
82
96
  (req.body && req.body.responseType);
83
97
 
84
98
  return {
85
- channelId: (channelId || (threadId ? 'uncategorized' : 'uncategorized')) as string, // Default to uncategorized if none
99
+ channelId: (rawChannelId || UNCATEGORIZED_CHANNEL_ID) as string,
100
+ rawChannelId,
86
101
  threadId: threadId as string | undefined,
87
102
  agentId: agentId as string | undefined,
88
103
  runId: runId as string,
@@ -150,18 +165,71 @@ export async function startServer(options: ServerOptions = {}) {
150
165
  };
151
166
  };
152
167
 
168
+ const broadcastActiveRunsSnapshot = (): void => {
169
+ const snapshot = buildActiveRunsSnapshot();
170
+ ensureEventId(snapshot);
171
+ sendToClientKey(GLOBAL_CHANNEL_ID, snapshot);
172
+ };
173
+
174
+ const persistLifecycleEvent = (
175
+ event: OpenBotEvent,
176
+ targetChannelId: string,
177
+ targetThreadId?: string,
178
+ ): void => {
179
+ ensureEventId(event);
180
+ storageService
181
+ .storeEvent({
182
+ channelId: targetChannelId,
183
+ threadId: targetThreadId,
184
+ event,
185
+ })
186
+ .catch((error) => {
187
+ console.error('[server] Failed to persist lifecycle event', {
188
+ type: event.type,
189
+ channelId: targetChannelId,
190
+ threadId: targetThreadId,
191
+ error,
192
+ });
193
+ });
194
+ };
195
+
153
196
  // Drop every tracked run for a channel/thread. A stop aborts the whole
154
197
  // chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
155
198
  // 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.
199
+ // orphaned entries that keep a channel falsely "active". Emit explicit
200
+ // `agent:run:end` for each purged run and refresh the global snapshot so
201
+ // clients stay in sync even when the parent harness stops yielding.
158
202
  const purgeActiveRunsForThread = (channelId: string, threadId?: string): void => {
159
203
  const target = threadId || undefined;
204
+ const removed: Array<{
205
+ runId: string;
206
+ channelId: string;
207
+ threadId?: string;
208
+ agentId: string;
209
+ }> = [];
210
+
160
211
  for (const [key, run] of activeRuns) {
161
212
  if (run.channelId === channelId && (run.threadId || undefined) === target) {
213
+ removed.push(run);
162
214
  activeRuns.delete(key);
163
215
  }
164
216
  }
217
+
218
+ for (const run of removed) {
219
+ const endEvent: OpenBotEvent = {
220
+ type: 'agent:run:end',
221
+ data: {
222
+ runId: run.runId,
223
+ agentId: run.agentId,
224
+ channelId: run.channelId,
225
+ threadId: run.threadId,
226
+ },
227
+ } as OpenBotEvent;
228
+ ensureEventId(endEvent);
229
+ persistLifecycleEvent(endEvent, run.channelId, run.threadId);
230
+ sendToClientKey(getClientKey(run.channelId, run.threadId), endEvent);
231
+ sendToClientKey(GLOBAL_CHANNEL_ID, endEvent);
232
+ }
165
233
  };
166
234
 
167
235
  // Support for Chrome's Private Network Access (PNA)
@@ -401,20 +469,38 @@ export async function startServer(options: ServerOptions = {}) {
401
469
  };
402
470
  const targetChannelId = data.channelId || channelId;
403
471
  const targetThreadId = data.threadId || threadId;
472
+ let resolvedStopAgentId = data.agentId || agentId || ORCHESTRATOR_AGENT_ID;
473
+ try {
474
+ const resolved = await resolveRespondingAgentId({
475
+ channelId: targetChannelId,
476
+ threadId: targetThreadId,
477
+ requestedAgentId: data.agentId || agentId,
478
+ });
479
+ resolvedStopAgentId = resolved.agentId;
480
+ } catch (error) {
481
+ console.warn('[publish] Failed to resolve responding agent for stop request', {
482
+ channelId: targetChannelId,
483
+ threadId: targetThreadId,
484
+ error,
485
+ });
486
+ }
404
487
  const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
405
488
  purgeActiveRunsForThread(targetChannelId, targetThreadId);
489
+ // Resync global clients even when nothing was tracked server-side.
490
+ broadcastActiveRunsSnapshot();
406
491
 
407
492
  const stoppedEvent: OpenBotEvent = {
408
493
  type: 'agent:run:stopped',
409
494
  data: {
410
495
  runId: data.runId || runId,
411
- agentId: data.agentId || agentId || ORCHESTRATOR_AGENT_ID,
496
+ agentId: resolvedStopAgentId,
412
497
  channelId: targetChannelId,
413
498
  threadId: targetThreadId,
414
499
  reason: data.reason,
415
500
  },
416
501
  } as OpenBotEvent;
417
502
  ensureEventId(stoppedEvent);
503
+ persistLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId);
418
504
  sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
419
505
  sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
420
506
 
@@ -443,6 +529,7 @@ export async function startServer(options: ServerOptions = {}) {
443
529
  );
444
530
  } else if (chunk.type === 'agent:run:stopped') {
445
531
  purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
532
+ broadcastActiveRunsSnapshot();
446
533
  }
447
534
 
448
535
  sendToClientKey(targetClientKey, chunk);
@@ -459,9 +546,31 @@ export async function startServer(options: ServerOptions = {}) {
459
546
  try {
460
547
  ensureEventId(event);
461
548
 
549
+ const isUserConversationStart =
550
+ event.type === 'agent:invoke' &&
551
+ event.data?.role === 'user' &&
552
+ typeof event.data.content === 'string' &&
553
+ event.data.content.trim().length > 0;
554
+
555
+ if (isUserConversationStart && channelId === UNCATEGORIZED_CHANNEL_ID) {
556
+ await storageService.ensureChannel({
557
+ channelId: UNCATEGORIZED_CHANNEL_ID,
558
+ spec: DEFAULT_UNCATEGORIZED_SPEC,
559
+ initialState: { name: 'Uncategorized' },
560
+ });
561
+ }
562
+
563
+ const bindIfUnbound = event.type === 'agent:invoke';
564
+ const resolved = await resolveRespondingAgentId({
565
+ channelId,
566
+ threadId,
567
+ requestedAgentId: agentId,
568
+ bindIfUnbound,
569
+ });
570
+
462
571
  await runAgent({
463
572
  runId,
464
- agentId: agentId || ORCHESTRATOR_AGENT_ID,
573
+ agentId: resolved.agentId,
465
574
  event,
466
575
  channelId,
467
576
  threadId,
@@ -470,6 +579,13 @@ export async function startServer(options: ServerOptions = {}) {
470
579
  });
471
580
  res.sendStatus(200);
472
581
  } catch (error) {
582
+ const message = error instanceof Error ? error.message : String(error);
583
+ const isUnknownAgent =
584
+ (error instanceof Error &&
585
+ ((error as Error & { code?: string }).code === 'AGENT_NOT_FOUND' ||
586
+ error.message.includes('does not exist'))) ||
587
+ message.includes('does not exist');
588
+
473
589
  console.error('[publish] Failed to dispatch event', {
474
590
  runId,
475
591
  channelId,
@@ -477,6 +593,12 @@ export async function startServer(options: ServerOptions = {}) {
477
593
  eventType: event.type,
478
594
  error,
479
595
  });
596
+
597
+ if (isUnknownAgent) {
598
+ res.status(400).json({ error: message });
599
+ return;
600
+ }
601
+
480
602
  res.status(500).json({ error: 'Failed to process publish event' });
481
603
  }
482
604
  });
@@ -493,6 +615,14 @@ export async function startServer(options: ServerOptions = {}) {
493
615
 
494
616
  const { channelId, threadId, agentId, runId } = getContext(req);
495
617
 
618
+ // In-memory active runs (not persisted). Mirrors the initial SSE frame on __global__.
619
+ if (channelId === GLOBAL_CHANNEL_ID && event.type === 'action:storage:get-active-runs') {
620
+ const snapshot = buildActiveRunsSnapshot();
621
+ ensureEventId(snapshot);
622
+ res.json({ events: [snapshot] });
623
+ return;
624
+ }
625
+
496
626
  if (event.type === 'action:storage:serve-file') {
497
627
  const filePath = (event.data as { path?: string })?.path;
498
628
  if (!channelId?.trim()) {