openbot 0.3.6 → 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 (104) hide show
  1. package/README.md +15 -16
  2. package/dist/app/agent-ids.js +4 -0
  3. package/dist/app/cli.js +1 -1
  4. package/dist/app/config.js +10 -19
  5. package/dist/app/server.js +208 -17
  6. package/dist/bus/services.js +34 -124
  7. package/dist/harness/agent-invoke-run.js +44 -0
  8. package/dist/harness/agent-turn.js +99 -0
  9. package/dist/harness/channel-participants.js +40 -0
  10. package/dist/harness/constants.js +2 -0
  11. package/dist/harness/context-meter.js +97 -0
  12. package/dist/harness/context.js +95 -47
  13. package/dist/harness/dispatch.js +144 -0
  14. package/dist/harness/dispatcher.js +45 -156
  15. package/dist/harness/history.js +177 -0
  16. package/dist/harness/index.js +109 -0
  17. package/dist/harness/orchestration.js +88 -0
  18. package/dist/harness/participants.js +22 -0
  19. package/dist/harness/run-harness.js +154 -0
  20. package/dist/harness/run.js +98 -0
  21. package/dist/harness/runtime-factory.js +0 -34
  22. package/dist/harness/runtime.js +57 -0
  23. package/dist/harness/todo-dispatch.js +51 -0
  24. package/dist/harness/todos.js +5 -0
  25. package/dist/harness/turn.js +79 -0
  26. package/dist/plugins/approval/index.js +120 -149
  27. package/dist/plugins/bash/index.js +195 -0
  28. package/dist/plugins/delegation/index.js +121 -32
  29. package/dist/plugins/memory/index.js +103 -14
  30. package/dist/plugins/memory/service.js +152 -0
  31. package/dist/plugins/openbot/context.js +125 -0
  32. package/dist/plugins/openbot/history.js +144 -0
  33. package/dist/plugins/openbot/index.js +71 -0
  34. package/dist/plugins/openbot/runtime.js +381 -0
  35. package/dist/plugins/openbot/system-prompt.js +25 -0
  36. package/dist/plugins/plugin-manager/index.js +189 -0
  37. package/dist/plugins/shell/index.js +2 -1
  38. package/dist/plugins/storage/files.js +67 -0
  39. package/dist/plugins/storage/index.js +750 -0
  40. package/dist/plugins/storage/service.js +1316 -0
  41. package/dist/plugins/storage-tools/index.js +2 -2
  42. package/dist/plugins/thread-namer/index.js +72 -0
  43. package/dist/plugins/thread-naming/generate-title.js +44 -0
  44. package/dist/plugins/thread-naming/index.js +103 -0
  45. package/dist/plugins/threads/index.js +114 -0
  46. package/dist/plugins/todo/index.js +24 -25
  47. package/dist/plugins/ui/index.js +109 -180
  48. package/dist/registry/plugins.js +3 -9
  49. package/dist/services/abort.js +43 -0
  50. package/dist/services/plugins/domain.js +1 -0
  51. package/dist/services/plugins/plugin-cache.js +9 -0
  52. package/dist/services/plugins/registry.js +112 -0
  53. package/dist/services/plugins/service.js +232 -0
  54. package/dist/services/plugins/types.js +1 -0
  55. package/dist/services/process.js +29 -0
  56. package/dist/services/storage.js +11 -10
  57. package/dist/services/thread-naming.js +81 -0
  58. package/docs/agents.md +15 -12
  59. package/docs/architecture.md +2 -2
  60. package/docs/plugins.md +29 -17
  61. package/docs/templates/AGENT.example.md +8 -14
  62. package/package.json +1 -2
  63. package/src/app/agent-ids.ts +5 -0
  64. package/src/app/cli.ts +1 -1
  65. package/src/app/config.ts +14 -31
  66. package/src/app/server.ts +243 -19
  67. package/src/app/types.ts +331 -187
  68. package/src/harness/index.ts +166 -0
  69. package/src/plugins/approval/index.ts +107 -188
  70. package/src/plugins/bash/index.ts +232 -0
  71. package/src/plugins/delegation/index.ts +139 -39
  72. package/src/plugins/memory/index.ts +112 -15
  73. package/src/{services/memory.ts → plugins/memory/service.ts} +1 -1
  74. package/src/plugins/openbot/context.ts +140 -0
  75. package/src/plugins/openbot/history.ts +158 -0
  76. package/src/plugins/openbot/index.ts +79 -0
  77. package/src/plugins/openbot/runtime.ts +478 -0
  78. package/src/plugins/openbot/system-prompt.ts +27 -0
  79. package/src/plugins/plugin-manager/index.ts +224 -0
  80. package/src/plugins/storage/files.ts +81 -0
  81. package/src/plugins/storage/index.ts +823 -0
  82. package/src/{services/storage.ts → plugins/storage/service.ts} +485 -105
  83. package/src/plugins/ui/index.ts +117 -221
  84. package/src/services/abort.ts +46 -0
  85. package/src/{bus/types.ts → services/plugins/domain.ts} +50 -8
  86. package/src/services/plugins/plugin-cache.ts +13 -0
  87. package/src/{registry/plugins.ts → services/plugins/registry.ts} +28 -28
  88. package/src/services/plugins/service.ts +318 -0
  89. package/src/{bus/plugin.ts → services/plugins/types.ts} +7 -3
  90. package/src/bus/services.ts +0 -954
  91. package/src/harness/context.ts +0 -365
  92. package/src/harness/dispatcher.ts +0 -379
  93. package/src/harness/mcp.ts +0 -78
  94. package/src/harness/runtime-factory.ts +0 -129
  95. package/src/harness/todo-advance.ts +0 -128
  96. package/src/plugins/ai-sdk/index.ts +0 -41
  97. package/src/plugins/ai-sdk/runtime.ts +0 -468
  98. package/src/plugins/ai-sdk/system-prompt.ts +0 -18
  99. package/src/plugins/mcp/index.ts +0 -128
  100. package/src/plugins/shell/index.ts +0 -123
  101. package/src/plugins/storage-tools/index.ts +0 -90
  102. package/src/plugins/todo/index.ts +0 -64
  103. package/src/services/plugins.ts +0 -133
  104. /package/src/{harness → services}/process.ts +0 -0
package/README.md CHANGED
@@ -18,7 +18,8 @@ OpenBot is a local-first harness for running AI agents. It is built around a sma
18
18
 
19
19
  - Runs a local agent server.
20
20
  - Stores channels, threads, agents, plugins, config, and variables under `~/.openbot`.
21
- - Ships with a built-in `system` agent named OpenBot.
21
+ - Ships with a built-in `system` agent named OpenBot (orchestrator, includes the LLM runtime).
22
+ - Ships with a built-in `state` agent for deterministic, non-LLM handling (e.g. `/api/state` defaults).
22
23
  - Loads custom agents from `~/.openbot/agents/<agent-id>/AGENT.md`.
23
24
  - Loads shared plugins from `~/.openbot/plugins`.
24
25
  - Streams events to clients with Server-Sent Events.
@@ -50,15 +51,17 @@ npm run dev
50
51
  OpenBot intentionally keeps the public API small:
51
52
 
52
53
  - `GET /api/events` opens an SSE stream for a channel or thread.
53
- - `POST /api/publish` publishes an event into the harness.
54
- - `GET /api/state` runs an event and returns the resulting events without opening a stream.
54
+ - `POST /api/publish` publishes an event into the harness (defaults to the built-in `system` agent with the OpenBot / LLM runtime).
55
+ - `GET /api/state` runs an event and returns the resulting events without opening a stream (defaults to the built-in `state` agent: storage-oriented plugins, no LLM).
56
+
57
+ You can override the agent with `agentId` (header, query, or body where applicable).
55
58
 
56
59
  Example:
57
60
 
58
61
  ```bash
59
62
  curl -X POST http://localhost:4132/api/publish \
60
63
  -H "content-type: application/json" \
61
- -d '{"type":"user:input","data":{"content":"hello"}}'
64
+ -d '{"type":"agent:invoke","data":{"role":"user","content":"hello"}}'
62
65
  ```
63
66
 
64
67
  Useful context can be passed as headers, query params, or body fields:
@@ -76,8 +79,7 @@ OpenBot reads config from `~/.openbot/config.json`.
76
79
  {
77
80
  "port": 4132,
78
81
  "baseDir": "~/.openbot",
79
- "model": "openai/gpt-4o-mini",
80
- "mcpServers": []
82
+ "model": "openai/gpt-4o-mini"
81
83
  }
82
84
  ```
83
85
 
@@ -91,12 +93,11 @@ The built-in `system` agent is always available. Add a custom agent by creating
91
93
  ---
92
94
  name: Researcher
93
95
  description: Helps collect and summarize information.
94
- runtime:
95
- name: ai-sdk
96
- config:
97
- model: openai/gpt-4o-mini
98
96
  plugins:
99
- - name: storage
97
+ - id: openbot
98
+ config:
99
+ model: openai/gpt-4o-mini
100
+ - id: storage
100
101
  ---
101
102
 
102
103
  You are a careful research assistant.
@@ -109,18 +110,16 @@ Agents are discovered from disk when the server starts.
109
110
 
110
111
  Built-in plugins include:
111
112
 
112
- - `storage`
113
+ - `storage-tools`
113
114
  - `delegation`
114
- - `mcp`
115
- - `ui`
116
- - `ai-sdk`
115
+ - `openbot`
117
116
 
118
117
  Shared plugins can be placed in `~/.openbot/plugins` and referenced by agents.
119
118
 
120
119
  ## Project Layout
121
120
 
122
121
  - `src/app`: CLI, server, event types, and app config.
123
- - `src/harness`: orchestration, process, and MCP runtime helpers.
122
+ - `src/harness`: orchestration and process helpers.
124
123
  - `src/plugins`: built-in plugin implementations.
125
124
  - `src/services`: local storage service.
126
125
  - `src/registry`: plugin registry.
@@ -0,0 +1,4 @@
1
+ /** Built-in orchestrator agent id. Optional `agents/system/AGENT.md` overrides code defaults. */
2
+ export const ORCHESTRATOR_AGENT_ID = 'system';
3
+ /** Built-in infra agent for deterministic `/api/state` and marketplace/plugin lifecycle; optional AGENT.md overlay. */
4
+ export const STATE_AGENT_ID = 'state';
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.3.6');
19
+ program.name('openbot').description('OpenBot CLI').version('0.4.2');
20
20
  program
21
21
  .command('start')
22
22
  .description('Start the OpenBot harness')
@@ -2,6 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  export const DEFAULT_BASE_DIR = '~/.openbot';
5
+ /** Default parent directory for per-channel working directories (user-facing workspace). */
6
+ export const DEFAULT_CHANNELS_WORKSPACE_DIR = '~/openbot';
5
7
  export const DEFAULT_PLUGINS_DIR = 'plugins';
6
8
  export const DEFAULT_AGENTS_DIR = 'agents';
7
9
  export const DEFAULT_CHANNELS_DIR = 'channels';
@@ -12,6 +14,14 @@ export const DEFAULT_MARKETPLACE_REGISTRY_URL = 'https://raw.githubusercontent.c
12
14
  export function resolvePath(p) {
13
15
  return p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : path.resolve(p);
14
16
  }
17
+ /** Default absolute cwd for a channel when none is provided at creation time. */
18
+ export function getDefaultChannelCwd(channelId) {
19
+ const id = channelId.trim();
20
+ if (!id) {
21
+ throw new Error('channelId is required');
22
+ }
23
+ return resolvePath(`${DEFAULT_CHANNELS_WORKSPACE_DIR}/${id}`);
24
+ }
15
25
  export function loadConfig() {
16
26
  const configPath = path.join(os.homedir(), '.openbot', CONFIG_FILE);
17
27
  if (fs.existsSync(configPath)) {
@@ -46,22 +56,3 @@ export function loadVariables() {
46
56
  }
47
57
  return { version: 1, variables: [] };
48
58
  }
49
- export const DEFAULT_AGENT_MD = `---
50
- description: A specialized AI agent
51
- ---
52
-
53
- # Agent Profile
54
-
55
- You are a specialized AI agent within the OpenBot system.
56
- Your role is defined by your configuration and the tools you have access to.
57
-
58
- ## Persona
59
- - Helpful and precise
60
- - Focused on my specific domain
61
- - Professional in all interactions
62
- `;
63
- export const DEFAULT_USER_MD = `# About Me
64
-
65
- <!-- OpenBot reads this file to understand who you are and how you like to work. -->
66
- <!-- Edit it here or just chat — agents can update it with the "remember" tool. -->
67
- `;
@@ -9,11 +9,13 @@ const require = createRequire(import.meta.url);
9
9
  const pkg = require('../../package.json');
10
10
  import { generateId } from 'melony';
11
11
  import { DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
12
- import { processService } from '../harness/process.js';
13
- import { storageService } from '../services/storage.js';
14
- import { dispatch } from '../harness/dispatcher.js';
15
- import { initPlugins } from '../registry/plugins.js';
12
+ import { processService } from '../services/process.js';
13
+ import { runAgent, STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
14
+ import { initPlugins } from '../services/plugins/registry.js';
15
+ import { storageService } from '../plugins/storage/service.js';
16
+ import { buildWorkspaceFileUrl, getPublicBaseUrl, openChannelFileStream, } from '../plugins/storage/files.js';
16
17
  import { ensureEventId, openBotEventFromQuery } from './utils.js';
18
+ import { abortRegistry, abortKey } from '../services/abort.js';
17
19
  export async function startServer(options = {}) {
18
20
  const publishEventSchema = z
19
21
  .object({
@@ -37,6 +39,9 @@ export async function startServer(options = {}) {
37
39
  await fs.mkdir(agentsDir, { recursive: true });
38
40
  await fs.mkdir(pluginsDir, { recursive: true });
39
41
  initPlugins(pluginsDir);
42
+ // Pre-warm caches for agents and plugins to speed up first UI load
43
+ storageService.getAgents().catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
44
+ storageService.getPlugins().catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
40
45
  const getContext = (req) => {
41
46
  const channelId = req.get('x-openbot-channel-id') || req.query.channelId || (req.body && req.body.channelId);
42
47
  const threadId = req.get('x-openbot-thread-id') || req.query.threadId || (req.body && req.body.threadId);
@@ -49,7 +54,7 @@ export async function startServer(options = {}) {
49
54
  req.query.responseType ||
50
55
  (req.body && req.body.responseType);
51
56
  return {
52
- channelId: (channelId || (threadId ? 'general' : 'general')), // Default to general if none
57
+ channelId: (channelId || (threadId ? 'uncategorized' : 'uncategorized')), // Default to uncategorized if none
53
58
  threadId: threadId,
54
59
  agentId: agentId,
55
60
  runId: runId,
@@ -60,8 +65,15 @@ export async function startServer(options = {}) {
60
65
  const getRunKey = (runId, agentId, channelId, threadId) => `${runId}:${agentId}:${channelId}:${threadId || ''}`;
61
66
  const sendToClientKey = (clientKey, chunk) => {
62
67
  const threadClients = clients.get(clientKey);
63
- if (!threadClients)
68
+ if (!threadClients || threadClients.length === 0)
64
69
  return;
70
+ // Auto-detect "read" state: if someone is listening, they just "read" this event.
71
+ if (chunk.id && clientKey !== GLOBAL_CHANNEL_ID) {
72
+ const parts = clientKey.split(':');
73
+ const channelId = parts[0];
74
+ const threadId = parts[1]; // undefined if no ":"
75
+ storageService.setLastRead({ channelId, threadId, lastReadEventId: chunk.id }).catch(() => { });
76
+ }
65
77
  threadClients.forEach((client) => {
66
78
  if (!client.writableEnded) {
67
79
  client.write(`data: ${JSON.stringify(chunk)}\n\n`);
@@ -104,8 +116,31 @@ export async function startServer(options = {}) {
104
116
  data: { channels },
105
117
  };
106
118
  };
119
+ // Drop every tracked run for a channel/thread. A stop aborts the whole
120
+ // chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
121
+ // 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.
124
+ const purgeActiveRunsForThread = (channelId, threadId) => {
125
+ const target = threadId || undefined;
126
+ for (const [key, run] of activeRuns) {
127
+ if (run.channelId === channelId && (run.threadId || undefined) === target) {
128
+ activeRuns.delete(key);
129
+ }
130
+ }
131
+ };
107
132
  app.use(cors());
108
- app.use(express.json({ limit: '20mb' }));
133
+ const resolvePublicBaseUrl = () => getPublicBaseUrl(PORT, config.publicUrl);
134
+ app.use((req, res, next) => {
135
+ const isWorkspaceUpload = req.method === 'POST' &&
136
+ req.path === '/api/publish' &&
137
+ req.get('x-openbot-event-type') === 'action:storage:upload-file';
138
+ if (isWorkspaceUpload) {
139
+ express.raw({ type: () => true, limit: '100mb' })(req, res, next);
140
+ return;
141
+ }
142
+ express.json({ limit: '20mb' })(req, res, next);
143
+ });
109
144
  app.get('/api/health', (req, res) => {
110
145
  res.json({ status: 'ok', version: pkg.version });
111
146
  });
@@ -129,6 +164,18 @@ export async function startServer(options = {}) {
129
164
  clients.set(clientKey, []);
130
165
  }
131
166
  clients.get(clientKey).push(res);
167
+ // Auto-detect "read" state on connection: mark the latest event as seen.
168
+ if (channelId !== GLOBAL_CHANNEL_ID) {
169
+ storageService
170
+ .getEvents({ channelId, threadId })
171
+ .then((events) => {
172
+ const latestId = events[events.length - 1]?.id;
173
+ if (latestId) {
174
+ return storageService.setLastRead({ channelId, threadId, lastReadEventId: latestId });
175
+ }
176
+ })
177
+ .catch(() => { });
178
+ }
132
179
  if (channelId === GLOBAL_CHANNEL_ID) {
133
180
  const snapshot = buildActiveRunsSnapshot();
134
181
  ensureEventId(snapshot);
@@ -156,6 +203,53 @@ export async function startServer(options = {}) {
156
203
  });
157
204
  });
158
205
  app.post('/api/publish', async (req, res) => {
206
+ if (req.get('x-openbot-event-type') === 'action:storage:upload-file') {
207
+ const channelId = req.get('x-openbot-channel-id') ||
208
+ (typeof req.query.channelId === 'string' ? req.query.channelId : undefined);
209
+ const filePath = req.get('x-openbot-file-path');
210
+ const overwrite = req.get('x-openbot-file-overwrite') === 'true';
211
+ if (!channelId?.trim()) {
212
+ res.status(400).json({ error: 'channelId is required' });
213
+ return;
214
+ }
215
+ if (!filePath?.trim()) {
216
+ res.status(400).json({ error: 'x-openbot-file-path header is required' });
217
+ return;
218
+ }
219
+ const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
220
+ if (body.length === 0) {
221
+ res.status(400).json({ error: 'Request body is empty' });
222
+ return;
223
+ }
224
+ try {
225
+ const result = await storageService.uploadChannelFile({
226
+ channelId: channelId.trim(),
227
+ path: filePath.trim(),
228
+ body,
229
+ overwrite,
230
+ });
231
+ const url = buildWorkspaceFileUrl({
232
+ baseUrl: resolvePublicBaseUrl(),
233
+ channelId: channelId.trim(),
234
+ filePath: result.path,
235
+ });
236
+ res.json({
237
+ type: 'action:storage:upload-file:result',
238
+ data: { success: true, ...result, url },
239
+ });
240
+ }
241
+ catch (error) {
242
+ res.status(400).json({
243
+ type: 'action:storage:upload-file:result',
244
+ data: {
245
+ success: false,
246
+ path: filePath,
247
+ error: error instanceof Error ? error.message : 'Upload failed',
248
+ },
249
+ });
250
+ }
251
+ return;
252
+ }
159
253
  const parseResult = publishEventSchema.safeParse(req.body);
160
254
  if (!parseResult.success) {
161
255
  res.status(400).json({
@@ -170,8 +264,77 @@ export async function startServer(options = {}) {
170
264
  res.status(400).json({ error: 'channelId is required' });
171
265
  return;
172
266
  }
267
+ if (event.type === 'action:storage:write-file') {
268
+ const data = (event.data ?? {});
269
+ if (!data.path?.trim()) {
270
+ res.status(400).json({
271
+ type: 'action:storage:write-file:result',
272
+ data: { success: false, path: '', error: 'path is required' },
273
+ });
274
+ return;
275
+ }
276
+ if (typeof data.content !== 'string') {
277
+ res.status(400).json({
278
+ type: 'action:storage:write-file:result',
279
+ data: { success: false, path: data.path, error: 'content is required' },
280
+ });
281
+ return;
282
+ }
283
+ try {
284
+ const result = await storageService.writeChannelFile({
285
+ channelId,
286
+ path: data.path.trim(),
287
+ content: data.content,
288
+ encoding: data.encoding ?? 'utf8',
289
+ overwrite: data.overwrite ?? false,
290
+ });
291
+ const url = buildWorkspaceFileUrl({
292
+ baseUrl: resolvePublicBaseUrl(),
293
+ channelId,
294
+ filePath: result.path,
295
+ });
296
+ res.json({
297
+ type: 'action:storage:write-file:result',
298
+ data: { success: true, ...result, url },
299
+ });
300
+ }
301
+ catch (error) {
302
+ res.status(400).json({
303
+ type: 'action:storage:write-file:result',
304
+ data: {
305
+ success: false,
306
+ path: data.path,
307
+ error: error instanceof Error ? error.message : 'Write failed',
308
+ },
309
+ });
310
+ }
311
+ return;
312
+ }
313
+ // Stop request: cancel the in-flight run (and any delegated sub-agents in the
314
+ // same thread) instead of spinning up a new agent turn.
315
+ if (event.type === 'action:agent_run_stop') {
316
+ const data = (event.data ?? {});
317
+ const targetChannelId = data.channelId || channelId;
318
+ const targetThreadId = data.threadId || threadId;
319
+ const stopped = abortRegistry.abort(abortKey(targetChannelId, targetThreadId));
320
+ purgeActiveRunsForThread(targetChannelId, targetThreadId);
321
+ const stoppedEvent = {
322
+ type: 'agent:run:stopped',
323
+ data: {
324
+ runId: data.runId || runId,
325
+ agentId: data.agentId || agentId || ORCHESTRATOR_AGENT_ID,
326
+ channelId: targetChannelId,
327
+ threadId: targetThreadId,
328
+ reason: data.reason,
329
+ },
330
+ };
331
+ ensureEventId(stoppedEvent);
332
+ sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
333
+ sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
334
+ res.json({ success: stopped });
335
+ return;
336
+ }
173
337
  const onEvent = async (chunk, state) => {
174
- ensureEventId(chunk);
175
338
  const targetChannelId = state?.channelId || channelId;
176
339
  const targetThreadId = state?.threadId || threadId;
177
340
  const targetClientKey = getClientKey(targetChannelId, targetThreadId);
@@ -186,11 +349,9 @@ export async function startServer(options = {}) {
186
349
  else if (chunk.type === 'agent:run:end') {
187
350
  activeRuns.delete(getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId));
188
351
  }
189
- await storageService.storeEvent({
190
- channelId: targetChannelId,
191
- threadId: targetThreadId,
192
- event: chunk,
193
- });
352
+ else if (chunk.type === 'agent:run:stopped') {
353
+ purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
354
+ }
194
355
  sendToClientKey(targetClientKey, chunk);
195
356
  if (chunk.type === 'agent:run:start' ||
196
357
  chunk.type === 'agent:run:end' ||
@@ -200,12 +361,13 @@ export async function startServer(options = {}) {
200
361
  };
201
362
  try {
202
363
  ensureEventId(event);
203
- await dispatch({
364
+ await runAgent({
204
365
  runId,
205
- agentId: agentId || 'system',
366
+ agentId: agentId || ORCHESTRATOR_AGENT_ID,
206
367
  event,
207
368
  channelId,
208
369
  threadId,
370
+ publicBaseUrl: resolvePublicBaseUrl(),
209
371
  onEvent,
210
372
  });
211
373
  res.sendStatus(200);
@@ -232,18 +394,47 @@ export async function startServer(options = {}) {
232
394
  return;
233
395
  }
234
396
  const { channelId, threadId, agentId, runId } = getContext(req);
397
+ if (event.type === 'action:storage:serve-file') {
398
+ const filePath = event.data?.path;
399
+ if (!channelId?.trim()) {
400
+ res.status(400).json({ error: 'channelId is required' });
401
+ return;
402
+ }
403
+ if (!filePath?.trim()) {
404
+ res.status(400).json({ error: 'path is required' });
405
+ return;
406
+ }
407
+ try {
408
+ const { abs, size, mimeType } = await storageService.getChannelFileStat({
409
+ channelId,
410
+ path: filePath.trim(),
411
+ });
412
+ res.setHeader('Content-Type', mimeType);
413
+ res.setHeader('Content-Length', String(size));
414
+ res.setHeader('Cache-Control', 'private, max-age=3600');
415
+ openChannelFileStream(abs).pipe(res);
416
+ }
417
+ catch (error) {
418
+ res.status(404).json({
419
+ error: error instanceof Error ? error.message : 'File not found',
420
+ });
421
+ }
422
+ return;
423
+ }
235
424
  const events = [];
236
425
  const onEvent = async (chunk) => {
237
426
  events.push(chunk);
238
427
  };
239
428
  try {
240
429
  ensureEventId(event);
241
- await dispatch({
430
+ await runAgent({
242
431
  runId,
243
- agentId: agentId || 'system',
432
+ agentId: agentId || STATE_AGENT_ID,
244
433
  event,
245
434
  channelId,
246
435
  threadId,
436
+ persistEvents: false,
437
+ publicBaseUrl: resolvePublicBaseUrl(),
247
438
  onEvent,
248
439
  });
249
440
  res.json({ events });
@@ -1,25 +1,6 @@
1
1
  import { DEFAULT_MARKETPLACE_REGISTRY_URL, loadConfig } from '../app/config.js';
2
2
  import { storageService } from '../services/storage.js';
3
3
  import { pluginService } from '../services/plugins.js';
4
- const readTodos = (state) => {
5
- const raw = state.threadDetails?.state?.todos;
6
- return Array.isArray(raw) ? raw : [];
7
- };
8
- let todoCounter = 0;
9
- const newTodoId = (now, idx) => `todo_${now.toString(36)}_${(todoCounter++).toString(36)}_${idx}`;
10
- async function persistTodos(storage, state, todos) {
11
- if (!state.threadId)
12
- throw new Error('No active thread');
13
- await storage.patchThreadState({
14
- channelId: state.channelId,
15
- threadId: state.threadId,
16
- state: { todos },
17
- });
18
- state.threadDetails = await storage.getThreadDetails({
19
- channelId: state.channelId,
20
- threadId: state.threadId,
21
- });
22
- }
23
4
  /**
24
5
  * Resolve a scope alias to a concrete scope string. Aliases let tools accept
25
6
  * `agent`/`channel`/`global` without knowing the active ids; the bus rewrites
@@ -44,26 +25,7 @@ function resolveMemoryScopeFilter(alias, state) {
44
25
  }
45
26
  return [resolveMemoryScope(alias, state)];
46
27
  }
47
- const DEFAULT_MARKETPLACE_AGENTS = [
48
- {
49
- id: 'researcher',
50
- name: 'Researcher',
51
- description: 'Specialized in web research and information synthesis.',
52
- instructions: 'You are a research assistant. Use available tools to find information.',
53
- plugins: [
54
- { id: 'ai-sdk', config: { model: 'openai/gpt-4o' } },
55
- { id: 'mcp' },
56
- { id: 'shell' },
57
- ],
58
- },
59
- {
60
- id: 'coder',
61
- name: 'Coder',
62
- description: 'Expert in multiple programming languages and software architecture.',
63
- instructions: 'You are an expert software engineer. Help the user with coding tasks.',
64
- plugins: [{ id: 'claude-code' }],
65
- },
66
- ];
28
+ const DEFAULT_MARKETPLACE_AGENTS = [];
67
29
  function isRecord(value) {
68
30
  return typeof value === 'object' && value !== null && !Array.isArray(value);
69
31
  }
@@ -311,6 +273,15 @@ export const busServicesPlugin = (options) => (builder) => {
311
273
  context.state.channelDetails = await storage.getChannelDetails({
312
274
  channelId: context.state.channelId,
313
275
  });
276
+ yield {
277
+ type: "client:ui:widget",
278
+ data: {
279
+ kind: "message",
280
+ title: "Channel details updated.",
281
+ body: "The channel details have been updated.",
282
+ },
283
+ meta: resultMeta,
284
+ };
314
285
  yield {
315
286
  type: 'action:patch_channel_details:result',
316
287
  data: { success: true, updatedFields },
@@ -358,91 +329,30 @@ export const busServicesPlugin = (options) => (builder) => {
358
329
  };
359
330
  }
360
331
  });
361
- builder.on('action:todo_write', async function* (event, context) {
362
- const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
363
- try {
364
- if (!context.state.threadId) {
365
- throw new Error('todo_write requires an active thread');
366
- }
367
- const existing = readTodos(context.state);
368
- const byId = new Map(existing.map((t) => [t.id, t]));
369
- const now = Date.now();
370
- const author = context.state.agentId || 'system';
371
- const inputs = event.data.todos || [];
372
- const next = inputs.map((raw, idx) => {
373
- const prior = raw.id ? byId.get(raw.id) : undefined;
374
- return {
375
- id: prior?.id || raw.id || newTodoId(now, idx),
376
- content: raw.content,
377
- status: raw.status || prior?.status || 'pending',
378
- assignee: raw.assignee ?? prior?.assignee,
379
- createdBy: prior?.createdBy || author,
380
- createdAt: prior?.createdAt || now,
381
- updatedAt: now,
382
- ...(prior?.result !== undefined ? { result: prior.result } : {}),
383
- };
384
- });
385
- await persistTodos(storage, context.state, next);
386
- yield {
387
- type: 'action:todo_write:result',
388
- data: { success: true, todos: next },
389
- meta: resultMeta,
390
- };
391
- }
392
- catch (error) {
393
- yield {
394
- type: 'action:todo_write:result',
395
- data: {
396
- success: false,
397
- todos: readTodos(context.state),
398
- error: error instanceof Error ? error.message : 'Unknown error',
399
- },
400
- meta: resultMeta,
401
- };
402
- }
403
- });
404
- builder.on('action:todo_update', async function* (event, context) {
405
- const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
406
- const patch = event.data;
407
- try {
408
- if (!context.state.threadId) {
409
- throw new Error('todo_update requires an active thread');
410
- }
411
- const existing = readTodos(context.state);
412
- const idx = existing.findIndex((t) => t.id === patch.id);
413
- if (idx === -1) {
414
- throw new Error(`Todo "${patch.id}" not found`);
415
- }
416
- const now = Date.now();
417
- const updated = {
418
- ...existing[idx],
419
- ...(patch.content !== undefined ? { content: patch.content } : {}),
420
- ...(patch.status !== undefined ? { status: patch.status } : {}),
421
- ...(patch.assignee !== undefined
422
- ? { assignee: patch.assignee === '' ? undefined : patch.assignee }
423
- : {}),
424
- updatedAt: now,
425
- };
426
- const next = [...existing];
427
- next[idx] = updated;
428
- await persistTodos(storage, context.state, next);
429
- yield {
430
- type: 'action:todo_update:result',
431
- data: { success: true, todo: updated, todos: next },
432
- meta: resultMeta,
433
- };
434
- }
435
- catch (error) {
436
- yield {
437
- type: 'action:todo_update:result',
438
- data: {
439
- success: false,
440
- todos: readTodos(context.state),
441
- error: error instanceof Error ? error.message : 'Unknown error',
442
- },
443
- meta: resultMeta,
444
- };
445
- }
332
+ builder.on('agent:usage', async function* (event, context) {
333
+ const { usage } = event.data;
334
+ if (!context.state.threadId)
335
+ return;
336
+ const currentState = context.state.threadDetails?.state || {};
337
+ const currentUsage = currentState.usage || {
338
+ promptTokens: 0,
339
+ completionTokens: 0,
340
+ totalTokens: 0,
341
+ };
342
+ const nextUsage = {
343
+ promptTokens: (currentUsage.promptTokens || 0) + usage.promptTokens,
344
+ completionTokens: (currentUsage.completionTokens || 0) + usage.completionTokens,
345
+ totalTokens: (currentUsage.totalTokens || 0) + usage.totalTokens,
346
+ };
347
+ await storage.patchThreadState({
348
+ channelId: context.state.channelId,
349
+ threadId: context.state.threadId,
350
+ state: { usage: nextUsage },
351
+ });
352
+ context.state.threadDetails = await storage.getThreadDetails({
353
+ channelId: context.state.channelId,
354
+ threadId: context.state.threadId,
355
+ });
446
356
  });
447
357
  builder.on('action:storage:get-channels', async function* () {
448
358
  const channels = await storage.getChannels();