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.
- package/.claude/agents/runtime-security-reviewer.md +32 -0
- package/.claude/hooks/format.sh +18 -0
- package/.claude/hooks/protect-secrets.sh +23 -0
- package/.claude/settings.json +26 -0
- package/.claude/skills/deploy/SKILL.md +26 -0
- package/.claude/skills/new-plugin/SKILL.md +46 -0
- package/.dockerignore +9 -0
- package/.mcp.json +8 -0
- package/AGENTS.md +2 -2
- package/CLAUDE.md +50 -0
- package/Dockerfile +41 -0
- package/deploy/README.md +61 -0
- package/deploy/entrypoint.sh +6 -0
- package/deploy/fly.toml +36 -0
- package/dist/app/bootstrap.js +29 -0
- package/dist/app/cli.js +3 -1
- package/dist/app/cloud-mode.js +26 -0
- package/dist/app/config.js +61 -16
- package/dist/app/server.js +38 -6
- package/dist/plugins/approval/index.js +1 -1
- package/dist/plugins/bash/index.js +386 -164
- package/dist/plugins/memory/service.js +2 -5
- package/dist/plugins/openbot/context.js +17 -1
- package/dist/plugins/openbot/index.js +23 -8
- package/dist/plugins/openbot/model.js +59 -0
- package/dist/plugins/openbot/runtime.js +39 -56
- package/dist/plugins/openbot/system-prompt.js +43 -8
- package/dist/plugins/preview/index.js +267 -0
- package/dist/plugins/storage/index.js +3 -3
- package/dist/plugins/storage/service.js +58 -12
- package/dist/plugins/todo/index.js +131 -38
- package/dist/plugins/todo/service.js +93 -0
- package/dist/plugins/ui/index.js +8 -1
- package/dist/services/plugins/model-registry.js +101 -0
- package/dist/services/plugins/registry.js +6 -4
- package/dist/services/plugins/service.js +2 -6
- package/docs/agents.md +20 -2
- package/docs/architecture.md +1 -1
- package/docs/plugins.md +13 -3
- package/docs/templates/AGENT.example.md +1 -1
- package/package.json +5 -2
- package/pnpm-workspace.yaml +2 -0
- package/src/app/bootstrap.ts +43 -0
- package/src/app/cli.ts +3 -1
- package/src/app/cloud-mode.ts +41 -0
- package/src/app/config.ts +71 -19
- package/src/app/server.ts +45 -6
- package/src/app/types.ts +162 -31
- package/src/plugins/approval/index.ts +1 -1
- package/src/plugins/bash/index.ts +498 -185
- package/src/plugins/memory/service.ts +2 -5
- package/src/plugins/openbot/context.ts +17 -1
- package/src/plugins/openbot/index.ts +25 -10
- package/src/plugins/openbot/model.ts +76 -0
- package/src/plugins/openbot/runtime.ts +47 -69
- package/src/plugins/openbot/system-prompt.ts +43 -8
- package/src/plugins/preview/index.ts +323 -0
- package/src/plugins/storage/index.ts +5 -8
- package/src/plugins/storage/service.ts +79 -15
- package/src/plugins/todo/index.ts +166 -0
- package/src/plugins/todo/service.ts +123 -0
- package/src/plugins/ui/index.ts +8 -1
- package/src/services/plugins/domain.ts +2 -0
- package/src/services/plugins/model-registry.ts +146 -0
- package/src/services/plugins/registry.ts +6 -4
- package/src/services/plugins/service.ts +2 -6
- package/dist/agents/openbot/index.js +0 -76
- package/dist/agents/openbot/middleware/approval.js +0 -132
- package/dist/agents/openbot/runtime.js +0 -289
- package/dist/agents/openbot/system-prompt.js +0 -32
- package/dist/agents/openbot/tools/delegation.js +0 -78
- package/dist/agents/openbot/tools/mcp.js +0 -99
- package/dist/agents/openbot/tools/shell.js +0 -91
- package/dist/agents/openbot/tools/storage.js +0 -75
- package/dist/agents/openbot/tools/ui.js +0 -176
- package/dist/agents/system.js +0 -33
- package/dist/bus/agent-package.js +0 -1
- package/dist/bus/plugin.js +0 -1
- package/dist/bus/services.js +0 -754
- package/dist/bus/types.js +0 -1
- package/dist/harness/agent-harness.js +0 -45
- package/dist/harness/agent-invoke-run.js +0 -44
- package/dist/harness/agent-turn.js +0 -99
- package/dist/harness/channel-participants.js +0 -40
- package/dist/harness/constants.js +0 -2
- package/dist/harness/context-meter.js +0 -97
- package/dist/harness/context.js +0 -363
- package/dist/harness/dispatch.js +0 -144
- package/dist/harness/dispatcher.js +0 -156
- package/dist/harness/event-normalizer.js +0 -59
- package/dist/harness/history.js +0 -177
- package/dist/harness/mcp.js +0 -61
- package/dist/harness/orchestration.js +0 -88
- package/dist/harness/orchestrator.js +0 -149
- package/dist/harness/participants.js +0 -22
- package/dist/harness/process.js +0 -29
- package/dist/harness/queue-processor.js +0 -187
- package/dist/harness/run-harness.js +0 -154
- package/dist/harness/run.js +0 -98
- package/dist/harness/runtime-factory.js +0 -73
- package/dist/harness/runtime.js +0 -57
- package/dist/harness/todo-advance.js +0 -93
- package/dist/harness/todo-dispatch.js +0 -51
- package/dist/harness/todos.js +0 -5
- package/dist/harness/turn.js +0 -79
- package/dist/harness/types.js +0 -1
- package/dist/plugins/ai-sdk/index.js +0 -34
- package/dist/plugins/ai-sdk/runtime.js +0 -395
- package/dist/plugins/ai-sdk/system-prompt.js +0 -18
- package/dist/plugins/ai-sdk.js +0 -331
- package/dist/plugins/approval.js +0 -163
- package/dist/plugins/delegation.js +0 -108
- package/dist/plugins/mcp/index.js +0 -108
- package/dist/plugins/mcp.js +0 -140
- package/dist/plugins/shell/index.js +0 -100
- package/dist/plugins/shell.js +0 -123
- package/dist/plugins/storage-tools/index.js +0 -76
- package/dist/plugins/storage.js +0 -737
- package/dist/plugins/thread-namer/index.js +0 -72
- package/dist/plugins/threads/index.js +0 -114
- package/dist/plugins/ui.js +0 -211
- package/dist/plugins/workflow/index.js +0 -65
- package/dist/registry/agents.js +0 -138
- package/dist/registry/plugins.js +0 -110
- package/dist/services/agent-packages.js +0 -103
- package/dist/services/memory.js +0 -152
- package/dist/services/plugins.js +0 -98
- package/dist/services/storage.js +0 -1028
- package/dist/workflow/service.js +0 -106
- package/dist/workflow/types.js +0 -3
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { MelonyPlugin } from 'melony';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import type { Plugin } from '../../services/plugins/types.js';
|
|
6
|
+
import type { Storage } from '../../services/plugins/domain.js';
|
|
7
|
+
import { OpenBotEvent, OpenBotState } from '../../app/types.js';
|
|
8
|
+
|
|
9
|
+
const TUNNEL_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
10
|
+
const TUNNEL_READY_TIMEOUT_MS = 60_000;
|
|
11
|
+
const MAX_LOG_CHARS = 8_000;
|
|
12
|
+
|
|
13
|
+
const previewToolDefinitions = {
|
|
14
|
+
expose_port: {
|
|
15
|
+
description:
|
|
16
|
+
'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.',
|
|
17
|
+
inputSchema: z.object({
|
|
18
|
+
port: z
|
|
19
|
+
.number()
|
|
20
|
+
.int()
|
|
21
|
+
.min(1024)
|
|
22
|
+
.max(65535)
|
|
23
|
+
.describe('Local port of the running dev server (e.g. 5173).'),
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
unexpose_port: {
|
|
27
|
+
description:
|
|
28
|
+
'Stop the active Cloudflare preview tunnel for this channel and clear previewUrl from channel state.',
|
|
29
|
+
inputSchema: z.object({}),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
interface PreviewTunnel {
|
|
34
|
+
id: string;
|
|
35
|
+
channelId: string;
|
|
36
|
+
port: number;
|
|
37
|
+
url: string;
|
|
38
|
+
process: ChildProcess;
|
|
39
|
+
startedAt: number;
|
|
40
|
+
logs: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const tunnels = new Map<string, PreviewTunnel>();
|
|
44
|
+
const tunnelByChannel = new Map<string, string>();
|
|
45
|
+
|
|
46
|
+
const blockedPorts = (): Set<number> => {
|
|
47
|
+
const openbotPort = Number(process.env.PORT ?? 4132);
|
|
48
|
+
return new Set([22, 80, 443, openbotPort]);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const appendLog = (tunnel: PreviewTunnel, chunk: string) => {
|
|
52
|
+
tunnel.logs += chunk;
|
|
53
|
+
if (tunnel.logs.length > MAX_LOG_CHARS) {
|
|
54
|
+
tunnel.logs = tunnel.logs.slice(-MAX_LOG_CHARS);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const killTunnelProcess = (tunnel: PreviewTunnel) => {
|
|
59
|
+
const { process: child } = tunnel;
|
|
60
|
+
if (!child.pid) {
|
|
61
|
+
try {
|
|
62
|
+
child.kill();
|
|
63
|
+
} catch {
|
|
64
|
+
/* ignore */
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
child.kill('SIGTERM');
|
|
71
|
+
} catch {
|
|
72
|
+
try {
|
|
73
|
+
child.kill();
|
|
74
|
+
} catch {
|
|
75
|
+
/* ignore */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const removeTunnel = (tunnelId: string) => {
|
|
81
|
+
const tunnel = tunnels.get(tunnelId);
|
|
82
|
+
if (!tunnel) return;
|
|
83
|
+
killTunnelProcess(tunnel);
|
|
84
|
+
tunnels.delete(tunnelId);
|
|
85
|
+
if (tunnelByChannel.get(tunnel.channelId) === tunnelId) {
|
|
86
|
+
tunnelByChannel.delete(tunnel.channelId);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const stopPreviewForChannel = (channelId: string) => {
|
|
91
|
+
const tunnelId = tunnelByChannel.get(channelId);
|
|
92
|
+
if (tunnelId) {
|
|
93
|
+
removeTunnel(tunnelId);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const waitForTunnelUrl = (child: ChildProcess, timeoutMs: number): Promise<string> =>
|
|
98
|
+
new Promise((resolve, reject) => {
|
|
99
|
+
let buffer = '';
|
|
100
|
+
let settled = false;
|
|
101
|
+
|
|
102
|
+
const cleanup = () => {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
child.stdout?.off('data', onData);
|
|
105
|
+
child.stderr?.off('data', onData);
|
|
106
|
+
child.off('exit', onExit);
|
|
107
|
+
child.off('error', onError);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const tryParse = () => {
|
|
111
|
+
const match = buffer.match(TUNNEL_URL_PATTERN);
|
|
112
|
+
if (match) {
|
|
113
|
+
settled = true;
|
|
114
|
+
cleanup();
|
|
115
|
+
resolve(match[0]);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const onData = (chunk: Buffer) => {
|
|
120
|
+
buffer += chunk.toString();
|
|
121
|
+
if (buffer.length > 16_000) {
|
|
122
|
+
buffer = buffer.slice(-16_000);
|
|
123
|
+
}
|
|
124
|
+
tryParse();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const onExit = (code: number | null) => {
|
|
128
|
+
if (settled) return;
|
|
129
|
+
settled = true;
|
|
130
|
+
cleanup();
|
|
131
|
+
reject(new Error(`cloudflared exited before providing a tunnel URL (code ${code ?? 'unknown'})`));
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const onError = (err: Error) => {
|
|
135
|
+
if (settled) return;
|
|
136
|
+
settled = true;
|
|
137
|
+
cleanup();
|
|
138
|
+
reject(err);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
if (settled) return;
|
|
143
|
+
settled = true;
|
|
144
|
+
cleanup();
|
|
145
|
+
reject(new Error('Timed out waiting for Cloudflare tunnel URL'));
|
|
146
|
+
}, timeoutMs);
|
|
147
|
+
|
|
148
|
+
child.stdout?.on('data', onData);
|
|
149
|
+
child.stderr?.on('data', onData);
|
|
150
|
+
child.on('exit', onExit);
|
|
151
|
+
child.on('error', onError);
|
|
152
|
+
tryParse();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const startCloudflaredTunnel = async (channelId: string, port: number): Promise<PreviewTunnel> => {
|
|
156
|
+
const child = spawn(
|
|
157
|
+
'cloudflared',
|
|
158
|
+
['tunnel', '--url', `http://127.0.0.1:${port}`, '--no-autoupdate'],
|
|
159
|
+
{
|
|
160
|
+
env: process.env,
|
|
161
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const tunnel: PreviewTunnel = {
|
|
166
|
+
id: randomUUID(),
|
|
167
|
+
channelId,
|
|
168
|
+
port,
|
|
169
|
+
url: '',
|
|
170
|
+
process: child,
|
|
171
|
+
startedAt: Date.now(),
|
|
172
|
+
logs: '',
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
child.stdout?.on('data', (data: Buffer) => appendLog(tunnel, data.toString()));
|
|
176
|
+
child.stderr?.on('data', (data: Buffer) => appendLog(tunnel, data.toString()));
|
|
177
|
+
|
|
178
|
+
child.on('exit', () => {
|
|
179
|
+
tunnels.delete(tunnel.id);
|
|
180
|
+
if (tunnelByChannel.get(channelId) === tunnel.id) {
|
|
181
|
+
tunnelByChannel.delete(channelId);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const url = await waitForTunnelUrl(child, TUNNEL_READY_TIMEOUT_MS);
|
|
186
|
+
tunnel.url = url;
|
|
187
|
+
tunnels.set(tunnel.id, tunnel);
|
|
188
|
+
tunnelByChannel.set(channelId, tunnel.id);
|
|
189
|
+
return tunnel;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const clearPreviewChannelState = async (storage: Storage, channelId: string) => {
|
|
193
|
+
await storage.patchChannelState({
|
|
194
|
+
channelId,
|
|
195
|
+
state: {
|
|
196
|
+
previewUrl: null,
|
|
197
|
+
previewPort: null,
|
|
198
|
+
previewExposedAt: null,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const previewPluginRuntime =
|
|
204
|
+
(storage: Storage): MelonyPlugin<OpenBotState, OpenBotEvent> =>
|
|
205
|
+
(builder) => {
|
|
206
|
+
builder.on('action:expose_port', async function* (event, context) {
|
|
207
|
+
const channelId = context.state.channelId;
|
|
208
|
+
const port = event.data?.port;
|
|
209
|
+
|
|
210
|
+
if (!Number.isInteger(port)) {
|
|
211
|
+
yield {
|
|
212
|
+
type: 'action:expose_port:result',
|
|
213
|
+
data: {
|
|
214
|
+
success: false,
|
|
215
|
+
output: 'port must be an integer between 1024 and 65535.',
|
|
216
|
+
},
|
|
217
|
+
meta: event.meta,
|
|
218
|
+
} as OpenBotEvent;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (blockedPorts().has(port)) {
|
|
223
|
+
yield {
|
|
224
|
+
type: 'action:expose_port:result',
|
|
225
|
+
data: {
|
|
226
|
+
success: false,
|
|
227
|
+
output: `Port ${port} is reserved and cannot be exposed.`,
|
|
228
|
+
},
|
|
229
|
+
meta: event.meta,
|
|
230
|
+
} as OpenBotEvent;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const existingTunnelId = tunnelByChannel.get(channelId);
|
|
235
|
+
if (existingTunnelId) {
|
|
236
|
+
removeTunnel(existingTunnelId);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const tunnel = await startCloudflaredTunnel(channelId, port);
|
|
241
|
+
|
|
242
|
+
await storage.patchChannelState({
|
|
243
|
+
channelId,
|
|
244
|
+
state: {
|
|
245
|
+
previewUrl: tunnel.url,
|
|
246
|
+
previewPort: port,
|
|
247
|
+
previewExposedAt: tunnel.startedAt,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (context.state.channelDetails) {
|
|
252
|
+
context.state.channelDetails = await storage.getChannelDetails({ channelId });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
yield {
|
|
256
|
+
type: 'action:expose_port:result',
|
|
257
|
+
data: {
|
|
258
|
+
success: true,
|
|
259
|
+
previewUrl: tunnel.url,
|
|
260
|
+
port,
|
|
261
|
+
temporary: true,
|
|
262
|
+
output: `Preview available at ${tunnel.url} (temporary Cloudflare quick tunnel).`,
|
|
263
|
+
},
|
|
264
|
+
meta: event.meta,
|
|
265
|
+
} as OpenBotEvent;
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const message =
|
|
268
|
+
error instanceof Error ? error.message : 'Failed to start Cloudflare tunnel';
|
|
269
|
+
const needsCloudflared =
|
|
270
|
+
message.includes('ENOENT') || message.toLowerCase().includes('cloudflared');
|
|
271
|
+
const hint = needsCloudflared
|
|
272
|
+
? ' Install cloudflared and ensure it is on PATH (https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/downloads/).'
|
|
273
|
+
: '';
|
|
274
|
+
|
|
275
|
+
yield {
|
|
276
|
+
type: 'action:expose_port:result',
|
|
277
|
+
data: {
|
|
278
|
+
success: false,
|
|
279
|
+
error: message,
|
|
280
|
+
output: `${message}${hint}`,
|
|
281
|
+
},
|
|
282
|
+
meta: event.meta,
|
|
283
|
+
} as OpenBotEvent;
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
builder.on('action:unexpose_port', async function* (event, context) {
|
|
288
|
+
const channelId = context.state.channelId;
|
|
289
|
+
|
|
290
|
+
stopPreviewForChannel(channelId);
|
|
291
|
+
await clearPreviewChannelState(storage, channelId);
|
|
292
|
+
|
|
293
|
+
if (context.state.channelDetails) {
|
|
294
|
+
context.state.channelDetails = await storage.getChannelDetails({ channelId });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
yield {
|
|
298
|
+
type: 'action:unexpose_port:result',
|
|
299
|
+
data: {
|
|
300
|
+
success: true,
|
|
301
|
+
output: 'Preview tunnel stopped and previewUrl cleared from channel state.',
|
|
302
|
+
},
|
|
303
|
+
meta: event.meta,
|
|
304
|
+
} as OpenBotEvent;
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
builder.on('action:delete_channel', async function* (event) {
|
|
308
|
+
const channelId = (event.data as { channelId?: string })?.channelId;
|
|
309
|
+
if (channelId) {
|
|
310
|
+
stopPreviewForChannel(channelId);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
export const previewPlugin: Plugin = {
|
|
316
|
+
id: 'preview',
|
|
317
|
+
name: 'Preview',
|
|
318
|
+
description: 'Temporary public preview URLs via Cloudflare quick tunnels.',
|
|
319
|
+
toolDefinitions: previewToolDefinitions,
|
|
320
|
+
factory: ({ storage }) => previewPluginRuntime(storage),
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export default previewPlugin;
|
|
@@ -38,9 +38,7 @@ const storageToolDefinitions = {
|
|
|
38
38
|
state: z
|
|
39
39
|
.record(z.string(), z.unknown())
|
|
40
40
|
.optional()
|
|
41
|
-
.describe(
|
|
42
|
-
'JSON state object for the channel. Use for structured metadata.',
|
|
43
|
-
),
|
|
41
|
+
.describe('JSON state object for the channel.'),
|
|
44
42
|
spec: z
|
|
45
43
|
.string()
|
|
46
44
|
.optional()
|
|
@@ -58,13 +56,12 @@ const storageToolDefinitions = {
|
|
|
58
56
|
),
|
|
59
57
|
},
|
|
60
58
|
patch_thread_details: {
|
|
61
|
-
description:
|
|
59
|
+
description:
|
|
60
|
+
'Patch current thread details (state). Use for thread metadata such as `name` or `isSmartNamed`. For multi-step task tracking, use `todo_write` instead.',
|
|
62
61
|
inputSchema: z.object({
|
|
63
62
|
state: z
|
|
64
63
|
.record(z.string(), z.unknown())
|
|
65
|
-
.describe(
|
|
66
|
-
'JSON state object for the thread. Use for structured progress or metadata.',
|
|
67
|
-
),
|
|
64
|
+
.describe('JSON state object for the thread. Merges with existing state.'),
|
|
68
65
|
}),
|
|
69
66
|
},
|
|
70
67
|
create_variable: {
|
|
@@ -333,7 +330,7 @@ export const storagePlugin: Plugin = {
|
|
|
333
330
|
display: "collapsed",
|
|
334
331
|
},
|
|
335
332
|
meta: resultMeta,
|
|
336
|
-
}
|
|
333
|
+
};
|
|
337
334
|
|
|
338
335
|
yield {
|
|
339
336
|
type: 'action:patch_channel_details:result',
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { ORCHESTRATOR_AGENT_ID, STATE_AGENT_ID } from '../../app/agent-ids.js';
|
|
2
|
+
import {
|
|
3
|
+
assertCloudSystemAgentPluginsMutable,
|
|
4
|
+
CLOUD_SYSTEM_MODEL,
|
|
5
|
+
isCloudMode,
|
|
6
|
+
} from '../../app/cloud-mode.js';
|
|
2
7
|
import {
|
|
3
8
|
DEFAULT_PLUGINS_DIR,
|
|
4
9
|
DEFAULT_AGENTS_DIR,
|
|
5
|
-
DEFAULT_BASE_DIR,
|
|
6
10
|
DEFAULT_CHANNELS_DIR,
|
|
11
|
+
getBaseDir,
|
|
7
12
|
getDefaultChannelCwd,
|
|
8
|
-
loadConfig,
|
|
9
13
|
resolvePath,
|
|
10
14
|
StoredVariable,
|
|
11
15
|
VARIABLES_FILE,
|
|
@@ -29,6 +33,10 @@ import {
|
|
|
29
33
|
import type { PluginRef } from '../../services/plugins/types.js';
|
|
30
34
|
import { openbotPlugin } from '../openbot/index.js';
|
|
31
35
|
import { listBuiltInPlugins, parsePluginModule } from '../../services/plugins/registry.js';
|
|
36
|
+
import {
|
|
37
|
+
enrichOpenbotPluginDescriptor,
|
|
38
|
+
listRegistryModelOptions,
|
|
39
|
+
} from '../../services/plugins/model-registry.js';
|
|
32
40
|
import { OpenBotEvent, OpenBotState } from '../../app/types.js';
|
|
33
41
|
import { processService } from '../../services/process.js';
|
|
34
42
|
import { memoryService } from '../memory/service.js';
|
|
@@ -38,10 +46,7 @@ import {
|
|
|
38
46
|
statChannelFile,
|
|
39
47
|
} from './files.js';
|
|
40
48
|
|
|
41
|
-
const resolveBaseDir = () =>
|
|
42
|
-
const config = loadConfig();
|
|
43
|
-
return resolvePath(config.baseDir || DEFAULT_BASE_DIR);
|
|
44
|
-
};
|
|
49
|
+
const resolveBaseDir = () => getBaseDir();
|
|
45
50
|
|
|
46
51
|
const ENTITY_SVG_CANDIDATE_NAMES = ['avatar.svg', 'icon.svg', 'image.svg', 'logo.svg'] as const;
|
|
47
52
|
|
|
@@ -121,12 +126,44 @@ const SYSTEM_DEFAULT_PLUGINS: PluginRef[] = [
|
|
|
121
126
|
config: {
|
|
122
127
|
model: 'openai/gpt-5.4-mini',
|
|
123
128
|
approval: {
|
|
124
|
-
actions: ['action:
|
|
129
|
+
actions: ['action:shell_exec', 'action:create_channel', 'action:delete_channel'],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const CLOUD_SYSTEM_DEFAULT_PLUGINS: PluginRef[] = [
|
|
136
|
+
{
|
|
137
|
+
id: 'openbot',
|
|
138
|
+
config: {
|
|
139
|
+
model: CLOUD_SYSTEM_MODEL,
|
|
140
|
+
approval: {
|
|
141
|
+
actions: ['action:shell_exec', 'action:create_channel', 'action:delete_channel'],
|
|
125
142
|
},
|
|
126
143
|
},
|
|
127
144
|
},
|
|
128
145
|
];
|
|
129
146
|
|
|
147
|
+
function mergeCloudSystemPluginRefs(
|
|
148
|
+
defaults: PluginRef[],
|
|
149
|
+
diskRefs: PluginRef[],
|
|
150
|
+
): PluginRef[] {
|
|
151
|
+
const defaultOpenbot = defaults.find((ref) => ref.id === 'openbot');
|
|
152
|
+
const diskOpenbot = diskRefs.find((ref) => ref.id === 'openbot');
|
|
153
|
+
|
|
154
|
+
if (!defaultOpenbot) return defaults;
|
|
155
|
+
|
|
156
|
+
return [
|
|
157
|
+
{
|
|
158
|
+
...defaultOpenbot,
|
|
159
|
+
config: {
|
|
160
|
+
...defaultOpenbot.config,
|
|
161
|
+
...diskOpenbot?.config,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
|
|
130
167
|
/** No `openbot` / `bash` — storage-side effects and infra plugins only. */
|
|
131
168
|
const STATE_DEFAULT_PLUGINS: PluginRef[] = [
|
|
132
169
|
{ id: 'storage' },
|
|
@@ -137,6 +174,7 @@ const STATE_AGENT_INSTRUCTIONS =
|
|
|
137
174
|
'Built-in infra agent for deterministic state reads. No conversational model is attached; handle storage, approvals, memory, and plugin marketplace events.';
|
|
138
175
|
|
|
139
176
|
function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails {
|
|
177
|
+
const defaultPlugins = isCloudMode() ? CLOUD_SYSTEM_DEFAULT_PLUGINS : SYSTEM_DEFAULT_PLUGINS;
|
|
140
178
|
const defaults: AgentDetails = {
|
|
141
179
|
id: SYSTEM_AGENT_ID,
|
|
142
180
|
name: 'OpenBot',
|
|
@@ -144,22 +182,41 @@ function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails
|
|
|
144
182
|
description:
|
|
145
183
|
'First-party orchestration agent for OpenBot.',
|
|
146
184
|
instructions: '',
|
|
147
|
-
plugins:
|
|
148
|
-
pluginRefs:
|
|
185
|
+
plugins: defaultPlugins.map((ref) => ref.id),
|
|
186
|
+
pluginRefs: defaultPlugins,
|
|
149
187
|
createdAt: new Date(),
|
|
150
188
|
updatedAt: new Date(),
|
|
151
189
|
};
|
|
152
190
|
|
|
153
191
|
if (!overrides) return defaults;
|
|
154
192
|
|
|
155
|
-
const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
|
|
156
|
-
? overrides.pluginRefs
|
|
157
|
-
: defaults.pluginRefs;
|
|
158
|
-
|
|
159
193
|
const diskInstructions = overrides.instructions?.trim();
|
|
160
194
|
const instructions =
|
|
161
195
|
diskInstructions && diskInstructions.length > 0 ? diskInstructions : defaults.instructions;
|
|
162
196
|
|
|
197
|
+
if (isCloudMode()) {
|
|
198
|
+
const diskRefs =
|
|
199
|
+
overrides.pluginRefs && overrides.pluginRefs.length > 0 ? overrides.pluginRefs : undefined;
|
|
200
|
+
const refs = diskRefs
|
|
201
|
+
? mergeCloudSystemPluginRefs(defaults.pluginRefs, diskRefs)
|
|
202
|
+
: defaults.pluginRefs;
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
...defaults,
|
|
206
|
+
...overrides,
|
|
207
|
+
id: SYSTEM_AGENT_ID,
|
|
208
|
+
instructions,
|
|
209
|
+
image: overrides.image || defaults.image,
|
|
210
|
+
plugins: refs.map((ref) => ref.id),
|
|
211
|
+
pluginRefs: refs,
|
|
212
|
+
updatedAt: new Date(),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
|
|
217
|
+
? overrides.pluginRefs
|
|
218
|
+
: defaults.pluginRefs;
|
|
219
|
+
|
|
163
220
|
return {
|
|
164
221
|
...defaults,
|
|
165
222
|
...overrides,
|
|
@@ -992,12 +1049,17 @@ export const storageService = {
|
|
|
992
1049
|
return Array.from(deduped.values()).filter((agent) => !agent.hidden);
|
|
993
1050
|
},
|
|
994
1051
|
getPlugins: async (): Promise<PluginDescriptor[]> => {
|
|
995
|
-
const [builtIn, fromDisk] = await Promise.all([
|
|
1052
|
+
const [builtIn, fromDisk, modelOptions] = await Promise.all([
|
|
996
1053
|
listBuiltInPluginDescriptors(),
|
|
997
1054
|
listPluginsFromDisk(),
|
|
1055
|
+
listRegistryModelOptions(),
|
|
998
1056
|
]);
|
|
999
1057
|
|
|
1000
|
-
const
|
|
1058
|
+
const enrichedBuiltIn = builtIn.map((plugin) =>
|
|
1059
|
+
enrichOpenbotPluginDescriptor(plugin, modelOptions),
|
|
1060
|
+
);
|
|
1061
|
+
|
|
1062
|
+
const merged = [...enrichedBuiltIn, ...fromDisk];
|
|
1001
1063
|
const deduped = new Map<string, PluginDescriptor>();
|
|
1002
1064
|
for (const plugin of merged) {
|
|
1003
1065
|
if (!deduped.has(plugin.id)) {
|
|
@@ -1081,6 +1143,7 @@ export const storageService = {
|
|
|
1081
1143
|
plugins: PluginRef[];
|
|
1082
1144
|
}): Promise<void> => {
|
|
1083
1145
|
assertAgentIdFormat(agentId);
|
|
1146
|
+
assertCloudSystemAgentPluginsMutable(agentId, plugins);
|
|
1084
1147
|
const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
|
|
1085
1148
|
const agentMdPath = path.join(agentDir, 'AGENT.md');
|
|
1086
1149
|
|
|
@@ -1133,6 +1196,7 @@ export const storageService = {
|
|
|
1133
1196
|
plugins?: PluginRef[];
|
|
1134
1197
|
}): Promise<void> => {
|
|
1135
1198
|
assertAgentIdFormat(agentId);
|
|
1199
|
+
assertCloudSystemAgentPluginsMutable(agentId, plugins);
|
|
1136
1200
|
const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
|
|
1137
1201
|
const agentMdPath = path.join(agentDir, 'AGENT.md');
|
|
1138
1202
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import type { Plugin } from '../../services/plugins/types.js';
|
|
3
|
+
import type { OpenBotEvent, UIWidgetListItem } from '../../app/types.js';
|
|
4
|
+
import { todoService, type TodoItem, type TodoList, type TodoStatus } from './service.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `todo` — thread-scoped task checklist for multi-step agent work.
|
|
8
|
+
*
|
|
9
|
+
* Persisted at `~/.openbot/channels/<channelId>/threads/<threadId>/todos.json`.
|
|
10
|
+
* The agent replaces the full list via `todo_write`; the runtime injects the
|
|
11
|
+
* current list into context each turn.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Map todo statuses onto UI list-item status label + variant. */
|
|
15
|
+
function toWidgetItemStatus(
|
|
16
|
+
status: TodoStatus,
|
|
17
|
+
): Pick<UIWidgetListItem, 'status' | 'statusVariant'> {
|
|
18
|
+
switch (status) {
|
|
19
|
+
case 'pending':
|
|
20
|
+
return { status: 'Pending', statusVariant: 'default' };
|
|
21
|
+
case 'in_progress':
|
|
22
|
+
return { status: 'In progress', statusVariant: 'info' };
|
|
23
|
+
case 'completed':
|
|
24
|
+
return { status: 'Done', statusVariant: 'success' };
|
|
25
|
+
case 'cancelled':
|
|
26
|
+
return { status: 'Cancelled', statusVariant: 'danger' };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatTodoOutput(list: TodoList): string {
|
|
31
|
+
if (list.items.length === 0) return 'Todo list is empty.';
|
|
32
|
+
const lines = list.items.map((t) => `- [${t.status}] (${t.id}) ${t.content}`);
|
|
33
|
+
return `Todos (${list.items.length}):\n${lines.join('\n')}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function todoListWidget(args: {
|
|
37
|
+
runId: string;
|
|
38
|
+
list: TodoList;
|
|
39
|
+
}): OpenBotEvent {
|
|
40
|
+
const items: UIWidgetListItem[] = args.list.items.map((t: TodoItem) => ({
|
|
41
|
+
id: t.id,
|
|
42
|
+
label: t.content,
|
|
43
|
+
...toWidgetItemStatus(t.status),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
type: 'client:ui:widget',
|
|
48
|
+
data: {
|
|
49
|
+
// One widget per agent run — repeated todo_write calls update in place.
|
|
50
|
+
widgetId: `todos:${args.runId}`,
|
|
51
|
+
kind: 'list',
|
|
52
|
+
title: 'Todos',
|
|
53
|
+
description:
|
|
54
|
+
args.list.items.length === 0
|
|
55
|
+
? 'No todos'
|
|
56
|
+
: `${args.list.items.filter((t) => t.status === 'completed').length}/${args.list.items.length} completed`,
|
|
57
|
+
items,
|
|
58
|
+
display: 'expanded',
|
|
59
|
+
state: 'open',
|
|
60
|
+
},
|
|
61
|
+
meta: {},
|
|
62
|
+
} as OpenBotEvent;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const todoItemSchema = z.object({
|
|
66
|
+
id: z.string().min(1).describe('Stable todo id (e.g. "1", "setup-repo").'),
|
|
67
|
+
content: z.string().min(1).describe('Short imperative description of the step.'),
|
|
68
|
+
status: z
|
|
69
|
+
.enum(['pending', 'in_progress', 'completed', 'cancelled'])
|
|
70
|
+
.describe('Todo status. At most one item may be in_progress.'),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const todoToolDefinitions = {
|
|
74
|
+
todo_write: {
|
|
75
|
+
description:
|
|
76
|
+
'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).',
|
|
77
|
+
inputSchema: z.object({
|
|
78
|
+
items: z
|
|
79
|
+
.array(todoItemSchema)
|
|
80
|
+
.max(20)
|
|
81
|
+
.describe('Full todo list for this thread (max 20). Replaces any previous list.'),
|
|
82
|
+
}),
|
|
83
|
+
},
|
|
84
|
+
todo_read: {
|
|
85
|
+
description:
|
|
86
|
+
'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.',
|
|
87
|
+
inputSchema: z.object({}),
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const todoPlugin: Plugin = {
|
|
92
|
+
id: 'todo',
|
|
93
|
+
name: 'Todo',
|
|
94
|
+
description:
|
|
95
|
+
'Thread-scoped todo checklist for multi-step task tracking (todo_write / todo_read).',
|
|
96
|
+
toolDefinitions: todoToolDefinitions,
|
|
97
|
+
factory: () => (builder) => {
|
|
98
|
+
builder.on('action:todo_write', async function* (event, context) {
|
|
99
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
100
|
+
try {
|
|
101
|
+
const channelId = context.state.channelId;
|
|
102
|
+
const threadId = context.state.threadId;
|
|
103
|
+
if (!channelId || !threadId) {
|
|
104
|
+
throw new Error('Missing channelId or threadId for todo_write');
|
|
105
|
+
}
|
|
106
|
+
const items = (event.data as { items: TodoItem[] }).items;
|
|
107
|
+
const list = await todoService.writeTodos({ channelId, threadId, items });
|
|
108
|
+
const runId = context.state.runId;
|
|
109
|
+
if (!runId) {
|
|
110
|
+
throw new Error('Missing runId for todo_write widget');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const widget = todoListWidget({ runId, list });
|
|
114
|
+
widget.meta = { ...resultMeta, threadId, runId };
|
|
115
|
+
yield widget;
|
|
116
|
+
|
|
117
|
+
yield {
|
|
118
|
+
type: 'action:todo_write:result',
|
|
119
|
+
data: { success: true, list, output: formatTodoOutput(list) },
|
|
120
|
+
meta: resultMeta,
|
|
121
|
+
} as OpenBotEvent;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
124
|
+
yield {
|
|
125
|
+
type: 'action:todo_write:result',
|
|
126
|
+
data: {
|
|
127
|
+
success: false,
|
|
128
|
+
error: message,
|
|
129
|
+
output: message,
|
|
130
|
+
},
|
|
131
|
+
meta: resultMeta,
|
|
132
|
+
} as OpenBotEvent;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
builder.on('action:todo_read', async function* (event, context) {
|
|
137
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
138
|
+
try {
|
|
139
|
+
const channelId = context.state.channelId;
|
|
140
|
+
const threadId = context.state.threadId;
|
|
141
|
+
if (!channelId || !threadId) {
|
|
142
|
+
throw new Error('Missing channelId or threadId for todo_read');
|
|
143
|
+
}
|
|
144
|
+
const list = await todoService.getTodos({ channelId, threadId });
|
|
145
|
+
yield {
|
|
146
|
+
type: 'action:todo_read:result',
|
|
147
|
+
data: { success: true, list, output: formatTodoOutput(list) },
|
|
148
|
+
meta: resultMeta,
|
|
149
|
+
} as OpenBotEvent;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
152
|
+
yield {
|
|
153
|
+
type: 'action:todo_read:result',
|
|
154
|
+
data: {
|
|
155
|
+
success: false,
|
|
156
|
+
error: message,
|
|
157
|
+
output: message,
|
|
158
|
+
},
|
|
159
|
+
meta: resultMeta,
|
|
160
|
+
} as OpenBotEvent;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export default todoPlugin;
|