openbot 0.4.7 → 0.5.4

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 (130) hide show
  1. package/.claude/agents/runtime-security-reviewer.md +32 -0
  2. package/.claude/hooks/format.sh +18 -0
  3. package/.claude/hooks/protect-secrets.sh +23 -0
  4. package/.claude/settings.json +26 -0
  5. package/.claude/skills/deploy/SKILL.md +26 -0
  6. package/.claude/skills/new-plugin/SKILL.md +46 -0
  7. package/.dockerignore +9 -0
  8. package/.mcp.json +8 -0
  9. package/AGENTS.md +2 -2
  10. package/CLAUDE.md +50 -0
  11. package/Dockerfile +41 -0
  12. package/deploy/README.md +61 -0
  13. package/deploy/entrypoint.sh +6 -0
  14. package/deploy/fly.toml +36 -0
  15. package/dist/app/bootstrap.js +29 -0
  16. package/dist/app/cli.js +3 -1
  17. package/dist/app/cloud-mode.js +26 -0
  18. package/dist/app/config.js +61 -16
  19. package/dist/app/server.js +38 -6
  20. package/dist/plugins/approval/index.js +1 -1
  21. package/dist/plugins/bash/index.js +386 -164
  22. package/dist/plugins/memory/service.js +2 -5
  23. package/dist/plugins/openbot/context.js +17 -1
  24. package/dist/plugins/openbot/index.js +23 -8
  25. package/dist/plugins/openbot/model.js +59 -0
  26. package/dist/plugins/openbot/runtime.js +39 -56
  27. package/dist/plugins/openbot/system-prompt.js +43 -8
  28. package/dist/plugins/preview/index.js +267 -0
  29. package/dist/plugins/storage/index.js +3 -3
  30. package/dist/plugins/storage/service.js +58 -12
  31. package/dist/plugins/todo/index.js +131 -38
  32. package/dist/plugins/todo/service.js +93 -0
  33. package/dist/plugins/ui/index.js +8 -1
  34. package/dist/services/plugins/model-registry.js +101 -0
  35. package/dist/services/plugins/registry.js +6 -4
  36. package/dist/services/plugins/service.js +2 -6
  37. package/docs/agents.md +20 -2
  38. package/docs/architecture.md +1 -1
  39. package/docs/plugins.md +13 -3
  40. package/docs/templates/AGENT.example.md +1 -1
  41. package/package.json +5 -2
  42. package/pnpm-workspace.yaml +2 -0
  43. package/src/app/bootstrap.ts +43 -0
  44. package/src/app/cli.ts +3 -1
  45. package/src/app/cloud-mode.ts +41 -0
  46. package/src/app/config.ts +71 -19
  47. package/src/app/server.ts +45 -6
  48. package/src/app/types.ts +162 -31
  49. package/src/plugins/approval/index.ts +1 -1
  50. package/src/plugins/bash/index.ts +498 -185
  51. package/src/plugins/memory/service.ts +2 -5
  52. package/src/plugins/openbot/context.ts +17 -1
  53. package/src/plugins/openbot/index.ts +25 -10
  54. package/src/plugins/openbot/model.ts +76 -0
  55. package/src/plugins/openbot/runtime.ts +47 -69
  56. package/src/plugins/openbot/system-prompt.ts +43 -8
  57. package/src/plugins/preview/index.ts +323 -0
  58. package/src/plugins/storage/index.ts +5 -8
  59. package/src/plugins/storage/service.ts +79 -15
  60. package/src/plugins/todo/index.ts +166 -0
  61. package/src/plugins/todo/service.ts +123 -0
  62. package/src/plugins/ui/index.ts +8 -1
  63. package/src/services/plugins/domain.ts +2 -0
  64. package/src/services/plugins/model-registry.ts +146 -0
  65. package/src/services/plugins/registry.ts +6 -4
  66. package/src/services/plugins/service.ts +2 -6
  67. package/dist/agents/openbot/index.js +0 -76
  68. package/dist/agents/openbot/middleware/approval.js +0 -132
  69. package/dist/agents/openbot/runtime.js +0 -289
  70. package/dist/agents/openbot/system-prompt.js +0 -32
  71. package/dist/agents/openbot/tools/delegation.js +0 -78
  72. package/dist/agents/openbot/tools/mcp.js +0 -99
  73. package/dist/agents/openbot/tools/shell.js +0 -91
  74. package/dist/agents/openbot/tools/storage.js +0 -75
  75. package/dist/agents/openbot/tools/ui.js +0 -176
  76. package/dist/agents/system.js +0 -33
  77. package/dist/bus/agent-package.js +0 -1
  78. package/dist/bus/plugin.js +0 -1
  79. package/dist/bus/services.js +0 -754
  80. package/dist/bus/types.js +0 -1
  81. package/dist/harness/agent-harness.js +0 -45
  82. package/dist/harness/agent-invoke-run.js +0 -44
  83. package/dist/harness/agent-turn.js +0 -99
  84. package/dist/harness/channel-participants.js +0 -40
  85. package/dist/harness/constants.js +0 -2
  86. package/dist/harness/context-meter.js +0 -97
  87. package/dist/harness/context.js +0 -363
  88. package/dist/harness/dispatch.js +0 -144
  89. package/dist/harness/dispatcher.js +0 -156
  90. package/dist/harness/event-normalizer.js +0 -59
  91. package/dist/harness/history.js +0 -177
  92. package/dist/harness/mcp.js +0 -61
  93. package/dist/harness/orchestration.js +0 -88
  94. package/dist/harness/orchestrator.js +0 -149
  95. package/dist/harness/participants.js +0 -22
  96. package/dist/harness/process.js +0 -29
  97. package/dist/harness/queue-processor.js +0 -187
  98. package/dist/harness/run-harness.js +0 -154
  99. package/dist/harness/run.js +0 -98
  100. package/dist/harness/runtime-factory.js +0 -73
  101. package/dist/harness/runtime.js +0 -57
  102. package/dist/harness/todo-advance.js +0 -93
  103. package/dist/harness/todo-dispatch.js +0 -51
  104. package/dist/harness/todos.js +0 -5
  105. package/dist/harness/turn.js +0 -79
  106. package/dist/harness/types.js +0 -1
  107. package/dist/plugins/ai-sdk/index.js +0 -34
  108. package/dist/plugins/ai-sdk/runtime.js +0 -395
  109. package/dist/plugins/ai-sdk/system-prompt.js +0 -18
  110. package/dist/plugins/ai-sdk.js +0 -331
  111. package/dist/plugins/approval.js +0 -163
  112. package/dist/plugins/delegation.js +0 -108
  113. package/dist/plugins/mcp/index.js +0 -108
  114. package/dist/plugins/mcp.js +0 -140
  115. package/dist/plugins/shell/index.js +0 -100
  116. package/dist/plugins/shell.js +0 -123
  117. package/dist/plugins/storage-tools/index.js +0 -76
  118. package/dist/plugins/storage.js +0 -737
  119. package/dist/plugins/thread-namer/index.js +0 -72
  120. package/dist/plugins/threads/index.js +0 -114
  121. package/dist/plugins/ui.js +0 -211
  122. package/dist/plugins/workflow/index.js +0 -65
  123. package/dist/registry/agents.js +0 -138
  124. package/dist/registry/plugins.js +0 -110
  125. package/dist/services/agent-packages.js +0 -103
  126. package/dist/services/memory.js +0 -152
  127. package/dist/services/plugins.js +0 -98
  128. package/dist/services/storage.js +0 -1028
  129. package/dist/workflow/service.js +0 -106
  130. package/dist/workflow/types.js +0 -3
@@ -0,0 +1,267 @@
1
+ import { z } from 'zod';
2
+ import { spawn } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
5
+ const TUNNEL_READY_TIMEOUT_MS = 60000;
6
+ const MAX_LOG_CHARS = 8000;
7
+ const previewToolDefinitions = {
8
+ expose_port: {
9
+ description: 'Expose a local dev server port via a temporary public Cloudflare quick tunnel. Returns a previewUrl stored on the channel. Dev servers must listen on 0.0.0.0 or 127.0.0.1. Call after shell_exec when the server is ready.',
10
+ inputSchema: z.object({
11
+ port: z
12
+ .number()
13
+ .int()
14
+ .min(1024)
15
+ .max(65535)
16
+ .describe('Local port of the running dev server (e.g. 5173).'),
17
+ }),
18
+ },
19
+ unexpose_port: {
20
+ description: 'Stop the active Cloudflare preview tunnel for this channel and clear previewUrl from channel state.',
21
+ inputSchema: z.object({}),
22
+ },
23
+ };
24
+ const tunnels = new Map();
25
+ const tunnelByChannel = new Map();
26
+ const blockedPorts = () => {
27
+ const openbotPort = Number(process.env.PORT ?? 4132);
28
+ return new Set([22, 80, 443, openbotPort]);
29
+ };
30
+ const appendLog = (tunnel, chunk) => {
31
+ tunnel.logs += chunk;
32
+ if (tunnel.logs.length > MAX_LOG_CHARS) {
33
+ tunnel.logs = tunnel.logs.slice(-MAX_LOG_CHARS);
34
+ }
35
+ };
36
+ const killTunnelProcess = (tunnel) => {
37
+ const { process: child } = tunnel;
38
+ if (!child.pid) {
39
+ try {
40
+ child.kill();
41
+ }
42
+ catch {
43
+ /* ignore */
44
+ }
45
+ return;
46
+ }
47
+ try {
48
+ child.kill('SIGTERM');
49
+ }
50
+ catch {
51
+ try {
52
+ child.kill();
53
+ }
54
+ catch {
55
+ /* ignore */
56
+ }
57
+ }
58
+ };
59
+ const removeTunnel = (tunnelId) => {
60
+ const tunnel = tunnels.get(tunnelId);
61
+ if (!tunnel)
62
+ return;
63
+ killTunnelProcess(tunnel);
64
+ tunnels.delete(tunnelId);
65
+ if (tunnelByChannel.get(tunnel.channelId) === tunnelId) {
66
+ tunnelByChannel.delete(tunnel.channelId);
67
+ }
68
+ };
69
+ export const stopPreviewForChannel = (channelId) => {
70
+ const tunnelId = tunnelByChannel.get(channelId);
71
+ if (tunnelId) {
72
+ removeTunnel(tunnelId);
73
+ }
74
+ };
75
+ const waitForTunnelUrl = (child, timeoutMs) => new Promise((resolve, reject) => {
76
+ let buffer = '';
77
+ let settled = false;
78
+ const cleanup = () => {
79
+ clearTimeout(timer);
80
+ child.stdout?.off('data', onData);
81
+ child.stderr?.off('data', onData);
82
+ child.off('exit', onExit);
83
+ child.off('error', onError);
84
+ };
85
+ const tryParse = () => {
86
+ const match = buffer.match(TUNNEL_URL_PATTERN);
87
+ if (match) {
88
+ settled = true;
89
+ cleanup();
90
+ resolve(match[0]);
91
+ }
92
+ };
93
+ const onData = (chunk) => {
94
+ buffer += chunk.toString();
95
+ if (buffer.length > 16000) {
96
+ buffer = buffer.slice(-16000);
97
+ }
98
+ tryParse();
99
+ };
100
+ const onExit = (code) => {
101
+ if (settled)
102
+ return;
103
+ settled = true;
104
+ cleanup();
105
+ reject(new Error(`cloudflared exited before providing a tunnel URL (code ${code ?? 'unknown'})`));
106
+ };
107
+ const onError = (err) => {
108
+ if (settled)
109
+ return;
110
+ settled = true;
111
+ cleanup();
112
+ reject(err);
113
+ };
114
+ const timer = setTimeout(() => {
115
+ if (settled)
116
+ return;
117
+ settled = true;
118
+ cleanup();
119
+ reject(new Error('Timed out waiting for Cloudflare tunnel URL'));
120
+ }, timeoutMs);
121
+ child.stdout?.on('data', onData);
122
+ child.stderr?.on('data', onData);
123
+ child.on('exit', onExit);
124
+ child.on('error', onError);
125
+ tryParse();
126
+ });
127
+ const startCloudflaredTunnel = async (channelId, port) => {
128
+ const child = spawn('cloudflared', ['tunnel', '--url', `http://127.0.0.1:${port}`, '--no-autoupdate'], {
129
+ env: process.env,
130
+ stdio: ['ignore', 'pipe', 'pipe'],
131
+ });
132
+ const tunnel = {
133
+ id: randomUUID(),
134
+ channelId,
135
+ port,
136
+ url: '',
137
+ process: child,
138
+ startedAt: Date.now(),
139
+ logs: '',
140
+ };
141
+ child.stdout?.on('data', (data) => appendLog(tunnel, data.toString()));
142
+ child.stderr?.on('data', (data) => appendLog(tunnel, data.toString()));
143
+ child.on('exit', () => {
144
+ tunnels.delete(tunnel.id);
145
+ if (tunnelByChannel.get(channelId) === tunnel.id) {
146
+ tunnelByChannel.delete(channelId);
147
+ }
148
+ });
149
+ const url = await waitForTunnelUrl(child, TUNNEL_READY_TIMEOUT_MS);
150
+ tunnel.url = url;
151
+ tunnels.set(tunnel.id, tunnel);
152
+ tunnelByChannel.set(channelId, tunnel.id);
153
+ return tunnel;
154
+ };
155
+ const clearPreviewChannelState = async (storage, channelId) => {
156
+ await storage.patchChannelState({
157
+ channelId,
158
+ state: {
159
+ previewUrl: null,
160
+ previewPort: null,
161
+ previewExposedAt: null,
162
+ },
163
+ });
164
+ };
165
+ const previewPluginRuntime = (storage) => (builder) => {
166
+ builder.on('action:expose_port', async function* (event, context) {
167
+ const channelId = context.state.channelId;
168
+ const port = event.data?.port;
169
+ if (!Number.isInteger(port)) {
170
+ yield {
171
+ type: 'action:expose_port:result',
172
+ data: {
173
+ success: false,
174
+ output: 'port must be an integer between 1024 and 65535.',
175
+ },
176
+ meta: event.meta,
177
+ };
178
+ return;
179
+ }
180
+ if (blockedPorts().has(port)) {
181
+ yield {
182
+ type: 'action:expose_port:result',
183
+ data: {
184
+ success: false,
185
+ output: `Port ${port} is reserved and cannot be exposed.`,
186
+ },
187
+ meta: event.meta,
188
+ };
189
+ return;
190
+ }
191
+ const existingTunnelId = tunnelByChannel.get(channelId);
192
+ if (existingTunnelId) {
193
+ removeTunnel(existingTunnelId);
194
+ }
195
+ try {
196
+ const tunnel = await startCloudflaredTunnel(channelId, port);
197
+ await storage.patchChannelState({
198
+ channelId,
199
+ state: {
200
+ previewUrl: tunnel.url,
201
+ previewPort: port,
202
+ previewExposedAt: tunnel.startedAt,
203
+ },
204
+ });
205
+ if (context.state.channelDetails) {
206
+ context.state.channelDetails = await storage.getChannelDetails({ channelId });
207
+ }
208
+ yield {
209
+ type: 'action:expose_port:result',
210
+ data: {
211
+ success: true,
212
+ previewUrl: tunnel.url,
213
+ port,
214
+ temporary: true,
215
+ output: `Preview available at ${tunnel.url} (temporary Cloudflare quick tunnel).`,
216
+ },
217
+ meta: event.meta,
218
+ };
219
+ }
220
+ catch (error) {
221
+ const message = error instanceof Error ? error.message : 'Failed to start Cloudflare tunnel';
222
+ const needsCloudflared = message.includes('ENOENT') || message.toLowerCase().includes('cloudflared');
223
+ const hint = needsCloudflared
224
+ ? ' Install cloudflared and ensure it is on PATH (https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/downloads/).'
225
+ : '';
226
+ yield {
227
+ type: 'action:expose_port:result',
228
+ data: {
229
+ success: false,
230
+ error: message,
231
+ output: `${message}${hint}`,
232
+ },
233
+ meta: event.meta,
234
+ };
235
+ }
236
+ });
237
+ builder.on('action:unexpose_port', async function* (event, context) {
238
+ const channelId = context.state.channelId;
239
+ stopPreviewForChannel(channelId);
240
+ await clearPreviewChannelState(storage, channelId);
241
+ if (context.state.channelDetails) {
242
+ context.state.channelDetails = await storage.getChannelDetails({ channelId });
243
+ }
244
+ yield {
245
+ type: 'action:unexpose_port:result',
246
+ data: {
247
+ success: true,
248
+ output: 'Preview tunnel stopped and previewUrl cleared from channel state.',
249
+ },
250
+ meta: event.meta,
251
+ };
252
+ });
253
+ builder.on('action:delete_channel', async function* (event) {
254
+ const channelId = event.data?.channelId;
255
+ if (channelId) {
256
+ stopPreviewForChannel(channelId);
257
+ }
258
+ });
259
+ };
260
+ export const previewPlugin = {
261
+ id: 'preview',
262
+ name: 'Preview',
263
+ description: 'Temporary public preview URLs via Cloudflare quick tunnels.',
264
+ toolDefinitions: previewToolDefinitions,
265
+ factory: ({ storage }) => previewPluginRuntime(storage),
266
+ };
267
+ export default previewPlugin;
@@ -32,7 +32,7 @@ const storageToolDefinitions = {
32
32
  state: z
33
33
  .record(z.string(), z.unknown())
34
34
  .optional()
35
- .describe('JSON state object for the channel. Use for structured metadata.'),
35
+ .describe('JSON state object for the channel.'),
36
36
  spec: z
37
37
  .string()
38
38
  .optional()
@@ -44,11 +44,11 @@ const storageToolDefinitions = {
44
44
  value.cwd !== undefined, { message: 'Provide at least one of state, spec, or cwd.' }),
45
45
  },
46
46
  patch_thread_details: {
47
- description: 'Patch current thread details (state).',
47
+ description: 'Patch current thread details (state). Use for thread metadata such as `name` or `isSmartNamed`. For multi-step task tracking, use `todo_write` instead.',
48
48
  inputSchema: z.object({
49
49
  state: z
50
50
  .record(z.string(), z.unknown())
51
- .describe('JSON state object for the thread. Use for structured progress or metadata.'),
51
+ .describe('JSON state object for the thread. Merges with existing state.'),
52
52
  }),
53
53
  },
54
54
  create_variable: {
@@ -1,5 +1,6 @@
1
1
  import { ORCHESTRATOR_AGENT_ID, STATE_AGENT_ID } from '../../app/agent-ids.js';
2
- import { DEFAULT_PLUGINS_DIR, DEFAULT_AGENTS_DIR, DEFAULT_BASE_DIR, DEFAULT_CHANNELS_DIR, getDefaultChannelCwd, loadConfig, resolvePath, VARIABLES_FILE, } from '../../app/config.js';
2
+ import { assertCloudSystemAgentPluginsMutable, CLOUD_SYSTEM_MODEL, isCloudMode, } from '../../app/cloud-mode.js';
3
+ import { DEFAULT_PLUGINS_DIR, DEFAULT_AGENTS_DIR, DEFAULT_CHANNELS_DIR, getBaseDir, getDefaultChannelCwd, resolvePath, VARIABLES_FILE, } from '../../app/config.js';
3
4
  import fs from 'node:fs/promises';
4
5
  import { readFileSync } from 'node:fs';
5
6
  import path from 'node:path';
@@ -8,13 +9,11 @@ import crypto from 'node:crypto';
8
9
  import matter from 'gray-matter';
9
10
  import { openbotPlugin } from '../openbot/index.js';
10
11
  import { listBuiltInPlugins, parsePluginModule } from '../../services/plugins/registry.js';
12
+ import { enrichOpenbotPluginDescriptor, listRegistryModelOptions, } from '../../services/plugins/model-registry.js';
11
13
  import { processService } from '../../services/process.js';
12
14
  import { memoryService } from '../memory/service.js';
13
15
  import { guessMimeType, resolveChannelFile, statChannelFile, } from './files.js';
14
- const resolveBaseDir = () => {
15
- const config = loadConfig();
16
- return resolvePath(config.baseDir || DEFAULT_BASE_DIR);
17
- };
16
+ const resolveBaseDir = () => getBaseDir();
18
17
  const ENTITY_SVG_CANDIDATE_NAMES = ['avatar.svg', 'icon.svg', 'image.svg', 'logo.svg'];
19
18
  const toSvgDataUrl = (svg) => `data:image/svg+xml;base64,${Buffer.from(svg, 'utf-8').toString('base64')}`;
20
19
  let bundledSystemAgentImage;
@@ -85,11 +84,37 @@ const SYSTEM_DEFAULT_PLUGINS = [
85
84
  config: {
86
85
  model: 'openai/gpt-5.4-mini',
87
86
  approval: {
88
- actions: ['action:bash', 'action:create_channel', 'action:delete_channel'],
87
+ actions: ['action:shell_exec', 'action:create_channel', 'action:delete_channel'],
88
+ },
89
+ },
90
+ },
91
+ ];
92
+ const CLOUD_SYSTEM_DEFAULT_PLUGINS = [
93
+ {
94
+ id: 'openbot',
95
+ config: {
96
+ model: CLOUD_SYSTEM_MODEL,
97
+ approval: {
98
+ actions: ['action:shell_exec', 'action:create_channel', 'action:delete_channel'],
89
99
  },
90
100
  },
91
101
  },
92
102
  ];
103
+ function mergeCloudSystemPluginRefs(defaults, diskRefs) {
104
+ const defaultOpenbot = defaults.find((ref) => ref.id === 'openbot');
105
+ const diskOpenbot = diskRefs.find((ref) => ref.id === 'openbot');
106
+ if (!defaultOpenbot)
107
+ return defaults;
108
+ return [
109
+ {
110
+ ...defaultOpenbot,
111
+ config: {
112
+ ...defaultOpenbot.config,
113
+ ...diskOpenbot?.config,
114
+ },
115
+ },
116
+ ];
117
+ }
93
118
  /** No `openbot` / `bash` — storage-side effects and infra plugins only. */
94
119
  const STATE_DEFAULT_PLUGINS = [
95
120
  { id: 'storage' },
@@ -97,24 +122,41 @@ const STATE_DEFAULT_PLUGINS = [
97
122
  ];
98
123
  const STATE_AGENT_INSTRUCTIONS = 'Built-in infra agent for deterministic state reads. No conversational model is attached; handle storage, approvals, memory, and plugin marketplace events.';
99
124
  function getSystemAgentDetails(overrides) {
125
+ const defaultPlugins = isCloudMode() ? CLOUD_SYSTEM_DEFAULT_PLUGINS : SYSTEM_DEFAULT_PLUGINS;
100
126
  const defaults = {
101
127
  id: SYSTEM_AGENT_ID,
102
128
  name: 'OpenBot',
103
129
  image: getBundledSystemAgentImage(),
104
130
  description: 'First-party orchestration agent for OpenBot.',
105
131
  instructions: '',
106
- plugins: SYSTEM_DEFAULT_PLUGINS.map((ref) => ref.id),
107
- pluginRefs: SYSTEM_DEFAULT_PLUGINS,
132
+ plugins: defaultPlugins.map((ref) => ref.id),
133
+ pluginRefs: defaultPlugins,
108
134
  createdAt: new Date(),
109
135
  updatedAt: new Date(),
110
136
  };
111
137
  if (!overrides)
112
138
  return defaults;
139
+ const diskInstructions = overrides.instructions?.trim();
140
+ const instructions = diskInstructions && diskInstructions.length > 0 ? diskInstructions : defaults.instructions;
141
+ if (isCloudMode()) {
142
+ const diskRefs = overrides.pluginRefs && overrides.pluginRefs.length > 0 ? overrides.pluginRefs : undefined;
143
+ const refs = diskRefs
144
+ ? mergeCloudSystemPluginRefs(defaults.pluginRefs, diskRefs)
145
+ : defaults.pluginRefs;
146
+ return {
147
+ ...defaults,
148
+ ...overrides,
149
+ id: SYSTEM_AGENT_ID,
150
+ instructions,
151
+ image: overrides.image || defaults.image,
152
+ plugins: refs.map((ref) => ref.id),
153
+ pluginRefs: refs,
154
+ updatedAt: new Date(),
155
+ };
156
+ }
113
157
  const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
114
158
  ? overrides.pluginRefs
115
159
  : defaults.pluginRefs;
116
- const diskInstructions = overrides.instructions?.trim();
117
- const instructions = diskInstructions && diskInstructions.length > 0 ? diskInstructions : defaults.instructions;
118
160
  return {
119
161
  ...defaults,
120
162
  ...overrides,
@@ -775,11 +817,13 @@ export const storageService = {
775
817
  return Array.from(deduped.values()).filter((agent) => !agent.hidden);
776
818
  },
777
819
  getPlugins: async () => {
778
- const [builtIn, fromDisk] = await Promise.all([
820
+ const [builtIn, fromDisk, modelOptions] = await Promise.all([
779
821
  listBuiltInPluginDescriptors(),
780
822
  listPluginsFromDisk(),
823
+ listRegistryModelOptions(),
781
824
  ]);
782
- const merged = [...builtIn, ...fromDisk];
825
+ const enrichedBuiltIn = builtIn.map((plugin) => enrichOpenbotPluginDescriptor(plugin, modelOptions));
826
+ const merged = [...enrichedBuiltIn, ...fromDisk];
783
827
  const deduped = new Map();
784
828
  for (const plugin of merged) {
785
829
  if (!deduped.has(plugin.id)) {
@@ -839,6 +883,7 @@ export const storageService = {
839
883
  },
840
884
  createAgent: async ({ agentId, name, description = '', image, hidden, instructions, plugins, }) => {
841
885
  assertAgentIdFormat(agentId);
886
+ assertCloudSystemAgentPluginsMutable(agentId, plugins);
842
887
  const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
843
888
  const agentMdPath = path.join(agentDir, 'AGENT.md');
844
889
  try {
@@ -874,6 +919,7 @@ export const storageService = {
874
919
  },
875
920
  updateAgent: async ({ agentId, name, description, image, hidden, instructions, plugins, }) => {
876
921
  assertAgentIdFormat(agentId);
922
+ assertCloudSystemAgentPluginsMutable(agentId, plugins);
877
923
  const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
878
924
  const agentMdPath = path.join(agentDir, 'AGENT.md');
879
925
  let raw;
@@ -1,53 +1,146 @@
1
1
  import z from 'zod';
2
- const todoStatus = z.enum(['pending', 'in_progress', 'done', 'cancelled']);
2
+ import { todoService } from './service.js';
3
+ /**
4
+ * `todo` — thread-scoped task checklist for multi-step agent work.
5
+ *
6
+ * Persisted at `~/.openbot/channels/<channelId>/threads/<threadId>/todos.json`.
7
+ * The agent replaces the full list via `todo_write`; the runtime injects the
8
+ * current list into context each turn.
9
+ */
10
+ /** Map todo statuses onto UI list-item status label + variant. */
11
+ function toWidgetItemStatus(status) {
12
+ switch (status) {
13
+ case 'pending':
14
+ return { status: 'Pending', statusVariant: 'default' };
15
+ case 'in_progress':
16
+ return { status: 'In progress', statusVariant: 'info' };
17
+ case 'completed':
18
+ return { status: 'Done', statusVariant: 'success' };
19
+ case 'cancelled':
20
+ return { status: 'Cancelled', statusVariant: 'danger' };
21
+ }
22
+ }
23
+ function formatTodoOutput(list) {
24
+ if (list.items.length === 0)
25
+ return 'Todo list is empty.';
26
+ const lines = list.items.map((t) => `- [${t.status}] (${t.id}) ${t.content}`);
27
+ return `Todos (${list.items.length}):\n${lines.join('\n')}`;
28
+ }
29
+ function todoListWidget(args) {
30
+ const items = args.list.items.map((t) => ({
31
+ id: t.id,
32
+ label: t.content,
33
+ ...toWidgetItemStatus(t.status),
34
+ }));
35
+ return {
36
+ type: 'client:ui:widget',
37
+ data: {
38
+ // One widget per agent run — repeated todo_write calls update in place.
39
+ widgetId: `todos:${args.runId}`,
40
+ kind: 'list',
41
+ title: 'Todos',
42
+ description: args.list.items.length === 0
43
+ ? 'No todos'
44
+ : `${args.list.items.filter((t) => t.status === 'completed').length}/${args.list.items.length} completed`,
45
+ items,
46
+ display: 'expanded',
47
+ state: 'open',
48
+ },
49
+ meta: {},
50
+ };
51
+ }
52
+ const todoItemSchema = z.object({
53
+ id: z.string().min(1).describe('Stable todo id (e.g. "1", "setup-repo").'),
54
+ content: z.string().min(1).describe('Short imperative description of the step.'),
55
+ status: z
56
+ .enum(['pending', 'in_progress', 'completed', 'cancelled'])
57
+ .describe('Todo status. At most one item may be in_progress.'),
58
+ });
3
59
  const todoToolDefinitions = {
4
60
  todo_write: {
5
- description: 'Manage the shared todo list (create, update, append, remove).',
61
+ description: 'Replace the current thread todo list with the provided items. Use for multi-step tasks: write the plan first, keep exactly one item in_progress while working, mark items completed immediately after finishing, and when done leave all items as completed (do not clear the list). Pass the full intended list each call (not a partial patch).',
6
62
  inputSchema: z.object({
7
- todos: z
8
- .array(z.object({
9
- id: z
10
- .string()
11
- .optional()
12
- .describe('Stable id. Reuse existing ids to update; omit to create.'),
13
- content: z.string().min(1).optional().describe('What needs to be done.'),
14
- status: todoStatus.optional().describe('Defaults to `pending`.'),
15
- assignee: z
16
- .string()
17
- .optional()
18
- .describe('Suggested agent id for this step (plain id, no @ prefix).'),
19
- deleted: z.boolean().optional().describe('If true, remove this item.'),
20
- }))
21
- .describe('List of todo items to write or patch.'),
22
- merge: z
23
- .boolean()
24
- .optional()
25
- .describe('If true (default), patches existing items by id and appends new ones. If false, replaces the entire list.'),
63
+ items: z
64
+ .array(todoItemSchema)
65
+ .max(20)
66
+ .describe('Full todo list for this thread (max 20). Replaces any previous list.'),
26
67
  }),
27
68
  },
28
- delegate_to_agent: {
29
- description: 'Run a worker agent on a self-contained task and return their output. ' +
30
- 'Call when a todo step should be executed by a participant; review the result and update todos before delegating again or replying to the user.',
31
- inputSchema: z.object({
32
- agentId: z
33
- .string()
34
- .min(1)
35
- .describe('Worker agent id from channel participants (plain id, no @ prefix).'),
36
- task: z
37
- .string()
38
- .min(1)
39
- .describe('Complete instruction for the worker — they do not see the full todo plan.'),
40
- todoId: z.string().optional().describe('Optional todo id this step relates to.'),
41
- }),
69
+ todo_read: {
70
+ description: 'Read the current thread todo list. Usually unnecessary the list is already injected into context each turn. Use only if you need an explicit refresh after an external change.',
71
+ inputSchema: z.object({}),
42
72
  },
43
73
  };
44
74
  export const todoPlugin = {
45
75
  id: 'todo',
46
76
  name: 'Todo',
47
- description: 'Shared todo list and worker delegation for multi-step orchestration.',
77
+ description: 'Thread-scoped todo checklist for multi-step task tracking (todo_write / todo_read).',
48
78
  toolDefinitions: todoToolDefinitions,
49
- factory: () => () => {
50
- // Handlers live in bus/services.ts.
79
+ factory: () => (builder) => {
80
+ builder.on('action:todo_write', async function* (event, context) {
81
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
82
+ try {
83
+ const channelId = context.state.channelId;
84
+ const threadId = context.state.threadId;
85
+ if (!channelId || !threadId) {
86
+ throw new Error('Missing channelId or threadId for todo_write');
87
+ }
88
+ const items = event.data.items;
89
+ const list = await todoService.writeTodos({ channelId, threadId, items });
90
+ const runId = context.state.runId;
91
+ if (!runId) {
92
+ throw new Error('Missing runId for todo_write widget');
93
+ }
94
+ const widget = todoListWidget({ runId, list });
95
+ widget.meta = { ...resultMeta, threadId, runId };
96
+ yield widget;
97
+ yield {
98
+ type: 'action:todo_write:result',
99
+ data: { success: true, list, output: formatTodoOutput(list) },
100
+ meta: resultMeta,
101
+ };
102
+ }
103
+ catch (error) {
104
+ const message = error instanceof Error ? error.message : 'Unknown error';
105
+ yield {
106
+ type: 'action:todo_write:result',
107
+ data: {
108
+ success: false,
109
+ error: message,
110
+ output: message,
111
+ },
112
+ meta: resultMeta,
113
+ };
114
+ }
115
+ });
116
+ builder.on('action:todo_read', async function* (event, context) {
117
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
118
+ try {
119
+ const channelId = context.state.channelId;
120
+ const threadId = context.state.threadId;
121
+ if (!channelId || !threadId) {
122
+ throw new Error('Missing channelId or threadId for todo_read');
123
+ }
124
+ const list = await todoService.getTodos({ channelId, threadId });
125
+ yield {
126
+ type: 'action:todo_read:result',
127
+ data: { success: true, list, output: formatTodoOutput(list) },
128
+ meta: resultMeta,
129
+ };
130
+ }
131
+ catch (error) {
132
+ const message = error instanceof Error ? error.message : 'Unknown error';
133
+ yield {
134
+ type: 'action:todo_read:result',
135
+ data: {
136
+ success: false,
137
+ error: message,
138
+ output: message,
139
+ },
140
+ meta: resultMeta,
141
+ };
142
+ }
143
+ });
51
144
  },
52
145
  };
53
146
  export default todoPlugin;